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(); 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.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_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_does_not_attach_duplicate_buildings_from_untracked_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 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.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()); } }