普通“添加排课”新增“理论课 / 实验课”类型。 自动排课会分别补足理论学时和实验学时。 实验课只能安排到实验室、实训室、机房、语音室等场地。 发布课表时分别校验理论、实验学时;任一未排足都不能发布。 普通课表及 Excel 导出会标注“实验课”。 历史排课保持不变,迁移后默认识别为理论课;后续新建或修订版本时再补充实验课。
249 lines
9.3 KiB
C#
249 lines
9.3 KiB
C#
using Jiaowu.Api.Domain.Academic;
|
|
using Jiaowu.Api.Infrastructure.Caching;
|
|
using Jiaowu.Api.Infrastructure.Persistence;
|
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace Jiaowu.Api.Tests;
|
|
|
|
public sealed class SchedulePublishJobProcessorTests
|
|
{
|
|
[Fact]
|
|
public async Task Processor_validates_and_publishes_plan_atomically()
|
|
{
|
|
var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 2);
|
|
|
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
|
Assert.Null(result.ActiveAcademicTermId);
|
|
Assert.Equal(5, result.CompletedSteps);
|
|
Assert.Equal("课表已发布", result.CurrentStep);
|
|
Assert.Null(result.ErrorMessage);
|
|
Assert.NotNull(result.PublishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Processor_keeps_draft_when_validation_fails()
|
|
{
|
|
var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 1);
|
|
|
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
|
Assert.Null(result.ActiveAcademicTermId);
|
|
Assert.Equal("检查未通过", result.CurrentStep);
|
|
Assert.Contains("理论课应安排 32 学时,当前已安排 16 学时", result.ErrorMessage);
|
|
Assert.Null(result.PublishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Processor_requires_experiment_hours_in_regular_schedule()
|
|
{
|
|
var result = await RunPublishAsync(
|
|
weeklyHours: 2,
|
|
scheduledHours: 1,
|
|
practiceHours: 16,
|
|
experimentScheduledHours: 1);
|
|
|
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Processor_rejects_missing_experiment_hours()
|
|
{
|
|
var result = await RunPublishAsync(
|
|
weeklyHours: 2,
|
|
scheduledHours: 1,
|
|
practiceHours: 16);
|
|
|
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
|
Assert.Contains("实验课应安排 16 学时,当前已安排 0 学时", result.ErrorMessage);
|
|
}
|
|
|
|
private static async Task<PublishResult> RunPublishAsync(
|
|
int weeklyHours,
|
|
int scheduledHours,
|
|
int practiceHours = 0,
|
|
int experimentScheduledHours = 0)
|
|
{
|
|
var databasePath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"jiaowu-publish-{Guid.NewGuid():N}.sqlite");
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDbContext<AppDbContext>(
|
|
options => options.UseSqlite(
|
|
$"Data Source={databasePath};Pooling=False"));
|
|
services.AddScoped<SchedulePlanPublisher>();
|
|
services.AddScoped<SchedulePublishJobProcessor>();
|
|
services.AddSingleton<IAppCache>(NoOpAppCache.Instance);
|
|
var provider = services.BuildServiceProvider();
|
|
|
|
try
|
|
{
|
|
Guid jobId;
|
|
await using (var seedScope = provider.CreateAsyncScope())
|
|
{
|
|
var db = seedScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await db.Database.EnsureCreatedAsync();
|
|
var term = new AcademicTerm
|
|
{
|
|
Code = $"2026-PUBLISH-{weeklyHours}",
|
|
Name = "后台发布学期",
|
|
AcademicYear = "2026-2027",
|
|
Season = TermSeason.Autumn,
|
|
StartDate = new DateOnly(2026, 9, 1),
|
|
EndDate = new DateOnly(2027, 1, 15)
|
|
};
|
|
var college = new College { Code = "PUB", Name = "发布测试学院" };
|
|
var campus = new Campus { Code = "PUB-CAMPUS", Name = "发布测试校区" };
|
|
var building = new Building
|
|
{
|
|
Code = "PUB-BUILDING",
|
|
Name = "实验楼",
|
|
Campus = campus
|
|
};
|
|
var laboratory = new Classroom
|
|
{
|
|
Code = "PUB-LAB",
|
|
Name = "发布测试实验室",
|
|
Building = building,
|
|
Capacity = 80,
|
|
RoomType = "实验室"
|
|
};
|
|
var course = new Course
|
|
{
|
|
Code = "PUB-01",
|
|
Name = "发布测试课程",
|
|
College = college,
|
|
Credits = 1,
|
|
TotalHours = 32,
|
|
LectureHours = 32 - practiceHours,
|
|
PracticeHours = practiceHours
|
|
};
|
|
var task = new TeachingTask
|
|
{
|
|
TaskNumber = $"TASK-PUB-{weeklyHours}",
|
|
Name = "发布测试教学班",
|
|
AcademicTerm = term,
|
|
Course = course,
|
|
Capacity = 60,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeeklyHours = weeklyHours,
|
|
Status = TeachingTaskStatus.Published
|
|
};
|
|
var plan = new SchedulePlan
|
|
{
|
|
AcademicTerm = term,
|
|
Name = "待发布课表",
|
|
Version = "V1"
|
|
};
|
|
plan.Entries.Add(new ScheduleEntry
|
|
{
|
|
TeachingTask = task,
|
|
DayOfWeek = 1,
|
|
StartPeriod = 1,
|
|
PeriodCount = scheduledHours,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeekPattern = WeekPattern.All
|
|
});
|
|
if (experimentScheduledHours > 0)
|
|
{
|
|
plan.Entries.Add(new ScheduleEntry
|
|
{
|
|
TeachingTask = task,
|
|
Kind = ScheduleEntryKind.Experiment,
|
|
Classroom = laboratory,
|
|
DayOfWeek = 2,
|
|
StartPeriod = 1,
|
|
PeriodCount = experimentScheduledHours,
|
|
StartWeek = 1,
|
|
EndWeek = 16,
|
|
WeekPattern = WeekPattern.All
|
|
});
|
|
}
|
|
var job = new SchedulePublishJob
|
|
{
|
|
SchedulePlan = plan,
|
|
AcademicTermId = term.Id,
|
|
ActiveAcademicTermId = term.Id,
|
|
CurrentStep = "等待后台检查"
|
|
};
|
|
jobId = job.Id;
|
|
db.AddRange(
|
|
term,
|
|
college,
|
|
campus,
|
|
building,
|
|
laboratory,
|
|
course,
|
|
task,
|
|
plan,
|
|
job);
|
|
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();
|
|
}
|
|
|
|
await using (var workerScope = provider.CreateAsyncScope())
|
|
{
|
|
var processor = workerScope.ServiceProvider
|
|
.GetRequiredService<SchedulePublishJobProcessor>();
|
|
await processor.ProcessAsync(jobId, CancellationToken.None);
|
|
}
|
|
|
|
await using var assertScope = provider.CreateAsyncScope();
|
|
var assertDb = assertScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var jobResult = await assertDb.SchedulePublishJobs.SingleAsync();
|
|
var planResult = await assertDb.SchedulePlans.SingleAsync();
|
|
return new(
|
|
jobResult.Status,
|
|
planResult.Status,
|
|
jobResult.ActiveAcademicTermId,
|
|
jobResult.CompletedSteps,
|
|
jobResult.CurrentStep,
|
|
jobResult.ErrorMessage,
|
|
planResult.PublishedAt);
|
|
}
|
|
finally
|
|
{
|
|
await provider.DisposeAsync();
|
|
File.Delete(databasePath);
|
|
}
|
|
}
|
|
|
|
private sealed record PublishResult(
|
|
SchedulePublishJobStatus JobStatus,
|
|
SchedulePlanStatus PlanStatus,
|
|
Guid? ActiveAcademicTermId,
|
|
int CompletedSteps,
|
|
string? CurrentStep,
|
|
string? ErrorMessage,
|
|
DateTime? PublishedAt);
|
|
}
|