Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs
T
2026-07-27 19:31:19 +08:00

407 lines
14 KiB
C#

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<AppDbContext>()
.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<DevelopmentSqliteMigrator>.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<int>(
"""
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<int>(
"""
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<int>(
"""
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<AppDbContext>()
.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<AppDbContext>()
.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<AutomaticScheduleProgress>();
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<AppDbContext>()
.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<Guid> { 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());
}
}