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 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( options => options.UseSqlite( $"Data Source={databasePath};Pooling=False")); services.AddScoped(); services.AddScoped(); services.AddSingleton(NoOpAppCache.Instance); var provider = services.BuildServiceProvider(); try { Guid jobId; await using (var seedScope = provider.CreateAsyncScope()) { var db = seedScope.ServiceProvider.GetRequiredService(); 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(); await processor.ProcessAsync(jobId, CancellationToken.None); } await using var assertScope = provider.CreateAsyncScope(); var assertDb = assertScope.ServiceProvider.GetRequiredService(); 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); }