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("尚未达到每周 2 个普通排课学时", result.ErrorMessage); Assert.Null(result.PublishedAt); } [Fact] public async Task Processor_excludes_practice_hours_for_existing_tasks() { var result = await RunPublishAsync( weeklyHours: 2, scheduledHours: 1, practiceHours: 16); Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus); Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus); } [Fact] public async Task Processor_rejects_practice_hours_already_added_to_draft() { var result = await RunPublishAsync( weeklyHours: 2, scheduledHours: 2, practiceHours: 16); Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus); Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus); Assert.Contains("普通课表应为 1 学时", result.ErrorMessage); Assert.Contains("删除已包含的实践学时", result.ErrorMessage); } private static async Task RunPublishAsync( int weeklyHours, int scheduledHours, int practiceHours = 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 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 }); var job = new SchedulePublishJob { SchedulePlan = plan, AcademicTermId = term.Id, ActiveAcademicTermId = term.Id, CurrentStep = "等待后台检查" }; jobId = job.Id; db.AddRange(term, college, 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); }