草稿计划支持选择场次后“批量移除已选场次”,一次最多 100 个。
批量操作会完整校验:包含不存在、其他计划或已发布计划的场次时整批拒绝,不会部分删除。[ExamsController.cs (line 384)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:384) 草稿计划新增“删除草稿”按钮,删除前显示计划名称及场次数量确认。[ExamsView.vue (line 292)](E:/jiaowu/web/src/views/ExamsView.vue:292) 删除草稿计划会级联清理场次和监考关联;已发布计划不能删除。[ExamsController.cs (line 81)](E:/jiaowu/src/Jiaowu.Api/Controllers/ExamsController.cs:81) 删除后会自动切换到其他计划;没有剩余计划时正确显示空状态。 新增草稿、已发布、跨计划混选及级联删除测试。[ExamDeletionControllerTests.cs (line 17)](E:/jiaowu/tests/Jiaowu.Api.Tests/ExamDeletionControllerTests.cs:17)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class ExamDeletionControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RemoveSessionsBatch_ValidatesCompleteSelectionBeforeRemoving()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var controller = fixture.CreateController();
|
||||
var selectedIds = fixture.DraftPlan.Sessions
|
||||
.Select(x => x.Id)
|
||||
.ToArray();
|
||||
var otherPlanSessionId = fixture.PublishedPlan.Sessions.Single().Id;
|
||||
|
||||
var invalidResult = await controller.RemoveSessionsBatch(
|
||||
fixture.DraftPlan.Id,
|
||||
new RemoveExamSessionsBatchRequest(
|
||||
[selectedIds[0], otherPlanSessionId]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(invalidResult);
|
||||
Assert.Equal(2, await fixture.Db.ExamSessions.CountAsync(
|
||||
x => x.ExamPlanId == fixture.DraftPlan.Id));
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(
|
||||
await controller.RemoveSessionsBatch(
|
||||
fixture.DraftPlan.Id,
|
||||
new RemoveExamSessionsBatchRequest(selectedIds),
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Equal(2, ReadIntProperty(result.Value!, "removedCount"));
|
||||
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
|
||||
x => x.ExamPlanId == fixture.DraftPlan.Id));
|
||||
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
|
||||
x => selectedIds.Contains(x.ExamSessionId)));
|
||||
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||
x => x.Id == fixture.DraftPlan.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveSessionsBatch_RejectsPublishedPlan()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var controller = fixture.CreateController();
|
||||
var sessionId = fixture.PublishedPlan.Sessions.Single().Id;
|
||||
|
||||
var result = await controller.RemoveSessionsBatch(
|
||||
fixture.PublishedPlan.Id,
|
||||
new RemoveExamSessionsBatchRequest([sessionId]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
Assert.True(await fixture.Db.ExamSessions.AnyAsync(
|
||||
x => x.Id == sessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePlan_DeletesDraftWithSessions_AndRejectsPublishedPlan()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var controller = fixture.CreateController();
|
||||
var draftPlanId = fixture.DraftPlan.Id;
|
||||
var publishedPlanId = fixture.PublishedPlan.Id;
|
||||
var draftSessionIds = fixture.DraftPlan.Sessions
|
||||
.Select(x => x.Id)
|
||||
.ToArray();
|
||||
|
||||
var publishedResult = await controller.DeletePlan(
|
||||
publishedPlanId,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(publishedResult);
|
||||
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||
x => x.Id == publishedPlanId));
|
||||
|
||||
var draftResult = await controller.DeletePlan(
|
||||
draftPlanId,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NoContentResult>(draftResult);
|
||||
Assert.False(await fixture.Db.ExamPlans.AnyAsync(
|
||||
x => x.Id == draftPlanId));
|
||||
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
|
||||
x => x.ExamPlanId == draftPlanId));
|
||||
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
|
||||
x => draftSessionIds.Contains(x.ExamSessionId)));
|
||||
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||
x => x.Id == publishedPlanId));
|
||||
}
|
||||
|
||||
private static int ReadIntProperty(object value, string name) =>
|
||||
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection connection;
|
||||
|
||||
private Fixture(
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
ExamPlan draftPlan,
|
||||
ExamPlan publishedPlan)
|
||||
{
|
||||
this.connection = connection;
|
||||
Db = db;
|
||||
DraftPlan = draftPlan;
|
||||
PublishedPlan = publishedPlan;
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
public ExamPlan DraftPlan { get; }
|
||||
public ExamPlan PublishedPlan { get; }
|
||||
|
||||
public static async Task<Fixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var college = new College
|
||||
{
|
||||
Code = "CS",
|
||||
Name = "计算机学院"
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-1",
|
||||
Name = "2026—2027 学年第一学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 20)
|
||||
};
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
CollegeId = college.Id,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
var firstTask = CreateTask(
|
||||
term.Id,
|
||||
course.Id,
|
||||
"2026-1-CS101-01");
|
||||
var secondTask = CreateTask(
|
||||
term.Id,
|
||||
course.Id,
|
||||
"2026-1-CS101-02");
|
||||
var invigilator = new Teacher
|
||||
{
|
||||
TeacherNumber = "T001",
|
||||
Name = "张老师",
|
||||
CollegeId = college.Id
|
||||
};
|
||||
var firstDraftSession = CreateSession(
|
||||
firstTask.Id,
|
||||
new DateOnly(2027, 1, 8));
|
||||
firstDraftSession.Invigilators =
|
||||
[
|
||||
new ExamSessionInvigilator
|
||||
{
|
||||
TeacherId = invigilator.Id
|
||||
}
|
||||
];
|
||||
var draftPlan = new ExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "期末考试草稿",
|
||||
Sessions =
|
||||
[
|
||||
firstDraftSession,
|
||||
CreateSession(secondTask.Id, new DateOnly(2027, 1, 9))
|
||||
]
|
||||
};
|
||||
var publishedPlan = new ExamPlan
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
Name = "已发布期末考试",
|
||||
Status = ExamPlanStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
Sessions =
|
||||
[
|
||||
CreateSession(firstTask.Id, new DateOnly(2027, 1, 10))
|
||||
]
|
||||
};
|
||||
|
||||
db.AddRange(
|
||||
college,
|
||||
term,
|
||||
course,
|
||||
firstTask,
|
||||
secondTask,
|
||||
invigilator,
|
||||
draftPlan,
|
||||
publishedPlan);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return new Fixture(
|
||||
connection,
|
||||
db,
|
||||
draftPlan,
|
||||
publishedPlan);
|
||||
}
|
||||
|
||||
public ExamsController CreateController() => new(
|
||||
Db,
|
||||
new ManagerDataScope(),
|
||||
new ExamArrangementService(Db),
|
||||
NoOpAppCache.Instance);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private static TeachingTask CreateTask(
|
||||
Guid termId,
|
||||
Guid courseId,
|
||||
string number) => new()
|
||||
{
|
||||
AcademicTermId = termId,
|
||||
CourseId = courseId,
|
||||
TaskNumber = number,
|
||||
Name = $"教学班 {number}",
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
|
||||
private static ExamSession CreateSession(
|
||||
Guid taskId,
|
||||
DateOnly examDate) => new()
|
||||
{
|
||||
TeachingTaskId = taskId,
|
||||
ExamDate = examDate,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
|
||||
EndsAt = examDate.ToDateTime(new TimeOnly(9, 50)),
|
||||
RequiredInvigilatorCount = 2
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
Guid.NewGuid(),
|
||||
"考试管理员",
|
||||
null,
|
||||
DataScope.All,
|
||||
new HashSet<string>([SystemRoles.AcademicAdmin]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user