diff --git a/.gitignore b/.gitignore index 5ca1366..3211410 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/ !.env.example !.env.docker.example certs/ +publish/ \ No newline at end of file diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 45cd414..791bcd7 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -101,6 +101,12 @@ public sealed class AppDbContext(DbContextOptions options) configurationBuilder.Properties() .HaveConversion() .HaveColumnType("date"); + + // Connector/NET returns MySQL TIME values as TimeSpan. Convert at the + // provider boundary so schedule time slots can still use TimeOnly. + configurationBuilder.Properties() + .HaveConversion() + .HaveColumnType("time"); } protected override void OnModelCreating(ModelBuilder builder) @@ -931,3 +937,8 @@ public sealed class DateOnlyDateTimeConverter() : ValueConverter( date => date.ToDateTime(TimeOnly.MinValue), value => DateOnly.FromDateTime(value)); + +public sealed class TimeOnlyTimeSpanConverter() + : ValueConverter( + time => time.ToTimeSpan(), + value => TimeOnly.FromTimeSpan(value)); diff --git a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs index 12c41c0..94717dc 100644 --- a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs @@ -4,6 +4,9 @@ using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using System.Collections; +using System.Text.Encodings.Web; +using System.Text.Json; namespace Jiaowu.Api.Tests; @@ -102,6 +105,206 @@ public sealed class ScheduleSettingsControllerTests }); } + [Fact] + public async Task Get_constraints_materializes_teachers_and_allowed_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 college = new College { Code = "CS", Name = "计算机学院" }; + var course = new Course + { + Code = "CS101", + Name = "程序设计基础", + College = college, + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16 + }; + var term = new AcademicTerm + { + Code = "2026-F", + Name = "2026 秋季", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 7), + EndDate = new DateOnly(2027, 1, 17) + }; + var teacher = new Teacher + { + TeacherNumber = "T001", + Name = "测试教师", + College = college + }; + var classroom = new Classroom + { + Code = "J1-201", + Name = "J1-201", + Capacity = 80, + Building = new Building + { + Code = "J1", + Name = "第一教学楼", + Campus = new Campus { Code = "MAIN", Name = "主校区" } + } + }; + var task = NewTask("TASK-01", course, term); + task.Teachers.Add(new TeachingTaskTeacher + { + Teacher = teacher, + IsPrimary = true + }); + var constraint = new TeachingTaskScheduleConstraint + { + TeachingTask = task, + AllowedDayOfWeeks = "1,3,5", + EarliestPeriod = 1, + LatestPeriod = 8, + AllowedClassrooms = + [ + new TeachingTaskAllowedClassroom { Classroom = classroom } + ] + }; + db.AddRange(college, course, term, teacher, classroom, task, constraint); + await db.SaveChangesAsync(); + + var result = await new ScheduleSettingsController(db) + .GetConstraints(term.Id, CancellationToken.None); + + var ok = Assert.IsType(result); + var payload = Assert.IsAssignableFrom(ok.Value); + Assert.Single(payload.Cast()); + var json = JsonSerializer.Serialize( + ok.Value, + new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + Assert.Contains("测试教师", json); + Assert.Contains(classroom.Id.ToString(), json); + } + + [Fact] + public async Task MySql_schedule_settings_materialize_time_slots_and_constraints() + { + var connectionString = + Environment.GetEnvironmentVariable("JIAOWU_TEST_MYSQL_CONNECTION"); + if (string.IsNullOrWhiteSpace(connectionString)) + return; + + var previousConnectionString = + Environment.GetEnvironmentVariable("ConnectionStrings__MySql"); + AppDbContext db; + try + { + Environment.SetEnvironmentVariable( + "ConnectionStrings__MySql", + connectionString); + db = new AppDbContextFactory().CreateDbContext([]); + } + finally + { + Environment.SetEnvironmentVariable( + "ConnectionStrings__MySql", + previousConnectionString); + } + await using var ownedDb = db; + var term = await db.AcademicTerms.FirstAsync(); + var course = await db.Courses.FirstAsync(); + var teacher = await db.Teachers.FirstAsync( + item => item.CollegeId == course.CollegeId); + var classroom = await db.Classrooms.FirstAsync(); + await using var transaction = await db.Database.BeginTransactionAsync(); + var periodNumber = await db.ScheduleTimeSlots + .Where(item => item.AcademicTermId == term.Id) + .Select(item => (int?)item.PeriodNumber) + .MaxAsync() ?? 0; + + var task = new TeachingTask + { + TaskNumber = $"MYSQL-{Guid.NewGuid():N}"[..22], + Name = "MySQL 排课约束回归", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 60, + WeeklyHours = 2, + StartWeek = 1, + EndWeek = 16, + Status = TeachingTaskStatus.Published, + Teachers = + [ + new TeachingTaskTeacher + { + TeacherId = teacher.Id, + IsPrimary = true + } + ] + }; + db.Add(task); + db.Add(new ScheduleTimeSlot + { + AcademicTermId = term.Id, + PeriodNumber = periodNumber + 1, + Name = "MySQL 回归节次", + StartsAt = new TimeOnly(8, 0), + EndsAt = new TimeOnly(8, 45) + }); + db.Add(new TeachingTaskScheduleConstraint + { + TeachingTask = task, + AllowedDayOfWeeks = "1,3,5", + EarliestPeriod = 1, + LatestPeriod = 8, + AllowedClassrooms = + [ + new TeachingTaskAllowedClassroom { ClassroomId = classroom.Id } + ] + }); + await db.SaveChangesAsync(); + db.ChangeTracker.Clear(); + + var result = await new ScheduleSettingsController(db) + .GetConstraints(term.Id, CancellationToken.None); + + var ok = Assert.IsType(result); + var payload = Assert.IsAssignableFrom(ok.Value); + Assert.Contains(payload.Cast(), item => + JsonSerializer.Serialize(item).Contains(task.TaskNumber)); + var timeSlotsResult = await new ScheduleSettingsController(db) + .GetTimeSlots(term.Id, CancellationToken.None); + var timeSlotsOk = Assert.IsType(timeSlotsResult); + var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value); + Assert.Contains("08:00", timeSlotsJson); + + var examTimeSlotsResult = await new ExamsController(db, null!, null!) + .GetTimeSlotsForTerm(term.Id, CancellationToken.None); + var examTimeSlotsOk = + Assert.IsType(examTimeSlotsResult); + Assert.Contains( + "08:00", + JsonSerializer.Serialize(examTimeSlotsOk.Value)); + + var makeupTimeSlotsResult = await new MakeupExamsController( + db, + null!, + null!, + null!, + null!) + .GetTimeSlotsForTerm(term.Id, CancellationToken.None); + var makeupTimeSlotsOk = + Assert.IsType(makeupTimeSlotsResult); + Assert.Contains( + "08:00", + JsonSerializer.Serialize(makeupTimeSlotsOk.Value)); + await transaction.RollbackAsync(); + } + private static TeachingTask NewTask( string taskNumber, Course course,