已把选课的“年级划分”和限制规则补成完整闭环:
选课批次可指定多个适用年级;留空兼容原有“全部年级”。 新增可选的“最多课程门数”,与原有学分上限并行。 年级、门数限制统一作用于学生可见批次、自主选课、候补、管理员代选和自动递补。 普通候选名单遵守年级及行政班范围;强制选课可绕过业务限制,但仍严格遵守管理员学院权限。 管理页面会展示“2026 级 · 最多 6 门 · 最多 30 学分”,名单和候补列表也增加年级列。 新增 MySQL 迁移:[CourseSelectionGradeLimits.cs](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726180000_CourseSelectionGradeLimits.cs)。
This commit is contained in:
@@ -81,6 +81,31 @@ public sealed class CourseSelectionRulesTests
|
||||
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_grade_scope_allows_all_students_and_configured_scope_is_enforced()
|
||||
{
|
||||
Assert.True(CourseSelectionRules.IsGradeEligible([], 2026));
|
||||
Assert.True(CourseSelectionRules.IsGradeEligible([2025, 2026], 2026));
|
||||
Assert.False(CourseSelectionRules.IsGradeEligible([2024, 2025], 2026));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 99, false)]
|
||||
[InlineData(6, 5, false)]
|
||||
[InlineData(6, 6, true)]
|
||||
[InlineData(6, 7, true)]
|
||||
public void Course_count_limit_is_optional_and_blocks_at_the_boundary(
|
||||
int? maxCourseCount,
|
||||
int selectedCourseCount,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
CourseSelectionRules.HasReachedCourseLimit(
|
||||
maxCourseCount,
|
||||
selectedCourseCount));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TeachingTaskSchedulingMode.Standard, true)]
|
||||
[InlineData(TeachingTaskSchedulingMode.Flexible, false)]
|
||||
|
||||
@@ -109,6 +109,89 @@ public sealed class CourseSelectionsControllerTests
|
||||
x.Title == "课程候补已结束"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Student_cannot_join_waitlist_outside_the_round_grade_scope()
|
||||
{
|
||||
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 data = await SeedFullOfferingAsync(db);
|
||||
|
||||
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
||||
{
|
||||
CourseSelectionRoundId = data.RoundId,
|
||||
Grade = 2025
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new CourseSelectionsController(
|
||||
db,
|
||||
new StudentDataScope(data.FirstWaiterUserId));
|
||||
var result = await controller.JoinWaitlist(
|
||||
new StudentEnrollmentRequest(data.OfferingId),
|
||||
CancellationToken.None);
|
||||
|
||||
var conflict = Assert.IsType<ConflictObjectResult>(result);
|
||||
var problem = Assert.IsType<ProblemDetails>(conflict.Value);
|
||||
Assert.Equal("你所在的年级不属于本轮选课对象。", problem.Detail);
|
||||
Assert.False(await db.CourseEnrollments.AnyAsync(x =>
|
||||
x.StudentId == data.FirstWaiterStudentId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updating_round_preserves_an_unchanged_grade_without_duplicate_rows()
|
||||
{
|
||||
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 data = await SeedFullOfferingAsync(db);
|
||||
|
||||
var round = await db.CourseSelectionRounds.FindAsync(data.RoundId);
|
||||
round!.Status = CourseSelectionRoundStatus.Draft;
|
||||
db.CourseSelectionRoundGrades.Add(new CourseSelectionRoundGrade
|
||||
{
|
||||
CourseSelectionRoundId = data.RoundId,
|
||||
Grade = 2026
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var request = new CourseSelectionRoundRequest(
|
||||
round.AcademicTermId,
|
||||
"2026 级第一轮选课",
|
||||
DateTime.UtcNow.AddHours(-1),
|
||||
DateTime.UtcNow.AddDays(7),
|
||||
DateTime.UtcNow.AddDays(14),
|
||||
30,
|
||||
6,
|
||||
[2026],
|
||||
null);
|
||||
var controller = new CourseSelectionsController(db, new AdminDataScope());
|
||||
|
||||
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
||||
data.RoundId,
|
||||
request,
|
||||
CancellationToken.None));
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.IsType<NoContentResult>(await controller.UpdateRound(
|
||||
data.RoundId,
|
||||
request,
|
||||
CancellationToken.None));
|
||||
Assert.Equal(
|
||||
1,
|
||||
await db.CourseSelectionRoundGrades.CountAsync(x =>
|
||||
x.CourseSelectionRoundId == data.RoundId &&
|
||||
x.Grade == 2026));
|
||||
}
|
||||
|
||||
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
|
||||
{
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class MySqlMigrationTests
|
||||
{
|
||||
private const string LatestMigration =
|
||||
"20260726170000_CourseSelectionWaitlist";
|
||||
"20260726180000_CourseSelectionGradeLimits";
|
||||
|
||||
[Fact]
|
||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||
@@ -31,6 +31,15 @@ public sealed class MySqlMigrationTests
|
||||
Assert.Contains(
|
||||
"CREATE INDEX `IX_CourseEnrollments_CourseSelectionOfferingId_Status_WaitlistedAt`",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"ADD `MaxCourseCount` int NULL",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"CREATE TABLE `CourseSelectionRoundGrades`",
|
||||
script);
|
||||
Assert.Contains(
|
||||
"CREATE UNIQUE INDEX `IX_CourseSelectionRoundGrades_CourseSelectionRoundId_Grade`",
|
||||
script);
|
||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||
Assert.Contains("DEFAULT 1", script);
|
||||
Assert.DoesNotContain("0001-01-01", script);
|
||||
|
||||
Reference in New Issue
Block a user