using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; namespace Jiaowu.Api.Tests; public sealed class AutomaticScheduleGeneratorTests { [Fact] public async Task Development_migration_chain_accepts_current_sqlite_schema() { 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(); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"BackgroundJobOutboxMessages\""); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"ExamRoomInvigilators\""); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"ExamSeats\""); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"ExamRoomSessions\""); await db.Database.ExecuteSqlRawAsync( "DROP TABLE \"ExamRooms\""); var migrator = new DevelopmentSqliteMigrator( db, NullLogger.Instance); await migrator.MigrateAsync(); Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0); Assert.True(await db.ExamRooms.CountAsync() == 0); Assert.True(await db.ExamRoomSessions.CountAsync() == 0); Assert.True(await db.ExamSeats.CountAsync() == 0); Assert.True(await db.ExamRoomInvigilators.CountAsync() == 0); Assert.True(await db.Database .SqlQueryRaw( """ SELECT COUNT(*) AS "Value" FROM pragma_index_list('BackgroundJobOutboxMessages') WHERE name = 'IX_BackgroundJobOutboxMessages_State_CompletedAt' """) .AnyAsync(value => value > 0)); Assert.True(await db.Database .SqlQueryRaw( """ SELECT COUNT(*) AS "Value" FROM pragma_table_info('ScheduleEntries') WHERE name = 'ClassroomId' AND "notnull" = 0 """) .AnyAsync(value => value > 0)); Assert.True(await db.Database .SqlQueryRaw( """ SELECT COUNT(*) AS "Value" FROM pragma_table_info('TeachingTasks') WHERE name = 'SchedulingMode' """) .AnyAsync(value => value > 0)); } [Fact] public async Task Generator_skips_flexible_courses() { 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 term = new AcademicTerm { Code = "2026-F", Name = "2026 秋季", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var college = new College { Code = "PUBLIC", Name = "公共教学部" }; var course = new Course { Code = "FLEX-01", Name = "社会实践", College = college, Credits = 1, TotalHours = 16, PracticeHours = 16 }; var task = new TeachingTask { TaskNumber = "TASK-FLEX", Name = "社会实践教学班", AcademicTerm = term, Course = course, Capacity = 100, StartWeek = 1, EndWeek = 16, WeeklyHours = 1, SchedulingMode = TeachingTaskSchedulingMode.Flexible, Status = TeachingTaskStatus.Published }; var plan = new SchedulePlan { AcademicTerm = term, Name = "非排时课程测试", Version = "V1" }; db.AddRange(term, college, course, task, plan); db.ScheduleTimeSlots.Add(new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 1, Name = "第 1 节", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }); await db.SaveChangesAsync(); var result = await new AutomaticScheduleGenerator(db) .GenerateAsync(plan, CancellationToken.None); Assert.Equal(0, result.TotalTasks); Assert.Equal(0, result.CreatedEntries); Assert.Empty(await db.ScheduleEntries.ToListAsync()); } [Fact] public async Task Generator_excludes_practice_hours_from_regular_schedule() { 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 term = new AcademicTerm { Code = "2026-LAB", Name = "2026 实验学时测试", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var college = new College { Code = "LAB", Name = "实验学院" }; var course = new Course { Code = "LAB-01", Name = "实验混合课程", College = college, Credits = 2, TotalHours = 32, LectureHours = 16, PracticeHours = 16 }; var task = new TeachingTask { TaskNumber = "TASK-LAB", Name = "实验混合教学班", AcademicTerm = term, Course = course, Capacity = 30, StartWeek = 1, EndWeek = 16, WeeklyHours = 2, Status = TeachingTaskStatus.Published }; var plan = new SchedulePlan { AcademicTerm = term, Name = "实验学时拆分测试", Version = "V1" }; db.AddRange(term, college, course, task, plan); db.ScheduleTimeSlots.AddRange( new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 1, Name = "第 1 节", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }, new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 2, Name = "第 2 节", StartsAt = new TimeOnly(8, 55), EndsAt = new TimeOnly(9, 40) }); db.TeachingTaskScheduleConstraints.Add( new TeachingTaskScheduleConstraint { TeachingTask = task, RequiresClassroom = false }); await db.SaveChangesAsync(); var result = await new AutomaticScheduleGenerator(db) .GenerateAsync(plan, CancellationToken.None); Assert.Equal(1, result.CreatedEntries); Assert.Equal(1, result.CompletedTasks); Assert.Equal( 1, (await db.ScheduleEntries.SingleAsync()).PeriodCount); } [Fact] public async Task Generator_schedules_roomless_course_within_allowed_time() { 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 term = new AcademicTerm { Code = "2026-A", Name = "2026 秋季", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var college = new College { Code = "CS", Name = "计算机学院" }; var course = new Course { Code = "MOOC-01", Name = "在线专题", College = college, Credits = 1, TotalHours = 32, LectureHours = 32 }; var task = new TeachingTask { TaskNumber = "TASK-MOOC", Name = "在线专题教学班", AcademicTerm = term, Course = course, Capacity = 200, StartWeek = 1, EndWeek = 16, WeeklyHours = 2, Status = TeachingTaskStatus.Published }; var plan = new SchedulePlan { AcademicTerm = term, Name = "自动排课测试", Version = "V1" }; db.AddRange(term, college, course, task, plan); db.ScheduleTimeSlots.AddRange( new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 1, Name = "第 1 节", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }, new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 2, Name = "第 2 节", StartsAt = new TimeOnly(8, 55), EndsAt = new TimeOnly(9, 40) }); db.TeachingTaskScheduleConstraints.Add(new TeachingTaskScheduleConstraint { TeachingTask = task, RequiresClassroom = false, AllowedDayOfWeeks = "3", EarliestPeriod = 1, LatestPeriod = 2 }); await db.SaveChangesAsync(); var progressUpdates = new List(); var result = await new AutomaticScheduleGenerator(db) .GenerateAsync( plan, (progress, _) => { progressUpdates.Add(progress); return Task.CompletedTask; }, true, CancellationToken.None); var entry = await db.ScheduleEntries.SingleAsync(); Assert.Equal(1, result.CreatedEntries); Assert.Empty(result.Messages); Assert.Collection( progressUpdates, progress => { Assert.Equal(1, progress.TotalTasks); Assert.Equal(0, progress.ProcessedTasks); }, progress => { Assert.Equal(1, progress.TotalTasks); Assert.Equal(1, progress.ProcessedTasks); Assert.Equal(1, progress.CreatedEntries); }); Assert.Equal(3, entry.DayOfWeek); Assert.Equal(1, entry.StartPeriod); Assert.Equal(2, entry.PeriodCount); Assert.Null(entry.ClassroomId); } [Fact] public async Task Generator_uses_scoped_rooms_without_attaching_duplicate_buildings() { 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 term = new AcademicTerm { Code = "2026-B", Name = "2026 秋季", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var campus = new Campus { Code = "MAIN", Name = "主校区" }; var building = new Building { Code = "A", Name = "教学楼 A", Campus = campus }; var smallRoom = new Classroom { Code = "A101", Name = "A101", Building = building, Capacity = 20 }; var largeRoom = new Classroom { Code = "A102", Name = "A102", Building = building, Capacity = 30 }; var college = new College { Code = "CS", Name = "计算机学院" }; var course = new Course { Code = "CS-01", Name = "程序设计", College = college, Credits = 2, TotalHours = 32, LectureHours = 32 }; var smallTask = new TeachingTask { TaskNumber = "TASK-SMALL", Name = "小班教学任务", AcademicTerm = term, Course = course, Capacity = 20, StartWeek = 1, EndWeek = 16, WeeklyHours = 2, Status = TeachingTaskStatus.Published }; var largeTask = new TeachingTask { TaskNumber = "TASK-LARGE", Name = "大班教学任务", AcademicTerm = term, Course = course, Capacity = 30, StartWeek = 1, EndWeek = 16, WeeklyHours = 2, Status = TeachingTaskStatus.Published }; var plan = new SchedulePlan { AcademicTerm = term, Name = "同楼多教室排课", Version = "V1" }; db.AddRange( term, campus, building, smallRoom, largeRoom, college, course, smallTask, largeTask, plan); db.ScheduleTimeSlots.AddRange( new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 1, Name = "第 1 节", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }, new ScheduleTimeSlot { AcademicTerm = term, PeriodNumber = 2, Name = "第 2 节", StartsAt = new TimeOnly(8, 55), EndsAt = new TimeOnly(9, 40) }); await db.SaveChangesAsync(); db.TeachingTaskScheduleConstraints.AddRange( new TeachingTaskScheduleConstraint { TeachingTaskId = smallTask.Id, RequiredCampusId = campus.Id, RequiredBuildingId = building.Id, AllowedClassrooms = [ new TeachingTaskAllowedClassroom { ClassroomId = smallRoom.Id } ] }, new TeachingTaskScheduleConstraint { TeachingTaskId = largeTask.Id, RequiredCampusId = campus.Id, RequiredBuildingId = building.Id, AllowedClassrooms = [ new TeachingTaskAllowedClassroom { ClassroomId = largeRoom.Id } ] }); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); plan = await db.SchedulePlans.SingleAsync(); var result = await new AutomaticScheduleGenerator(db) .GenerateAsync(plan, CancellationToken.None); var entries = await db.ScheduleEntries .OrderBy(x => x.TeachingTaskId) .ToListAsync(); Assert.Equal(2, result.CreatedEntries); Assert.Empty(result.Messages); Assert.Equal(2, entries.Count); Assert.True( new HashSet { smallRoom.Id, largeRoom.Id } .SetEquals(entries.Select(x => x.ClassroomId!.Value))); Assert.Equal(1, await db.Buildings.CountAsync()); Assert.Equal(2, await db.Classrooms.CountAsync()); } }