Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/ExperimentsControllerTests.cs
T
biss 9d525826ce 支持为同一学期、同一课程的多个教学任务批量创建相同实验项目。
新增批量排课表,可为多个实验项目分别设置日期、节次、实验室和容量。
批量排课采用原子事务:任一项目发生教室、教师、班级或课表冲突,整批不写入。
已发布项目批量新增场次时,继续按原逻辑通知学生。
后端继续执行管理范围、教学任务状态、开放日期和容量校验。
页面在 390px 手机宽度下无横向溢出。
2026-08-01 16:08:03 +08:00

668 lines
24 KiB
C#

using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExperimentsControllerTests
{
[Fact]
public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
var result = await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.Centralized),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
var projects = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.ToListAsync();
Assert.Equal(2, projects.Count);
Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count());
Assert.All(projects, project =>
{
Assert.Equal("LAB-BATCH", project.Code);
Assert.Equal("公共实验任务", project.Name);
Assert.Equal(ExperimentProjectStatus.Draft, project.Status);
});
}
[Fact]
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var projectIds = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.Select(x => x.Id)
.ToListAsync();
var result = await controller.CreateSessions(
new ExperimentSessionBatchRequest(
[
new ExperimentSessionBatchItem(
projectIds[0],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null),
new ExperimentSessionBatchItem(
projectIds[1],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null)
]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
fixture.Db.ChangeTracker.Clear();
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
}
[Fact]
public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var projectIds = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.Select(x => x.Id)
.ToListAsync();
var result = await controller.CreateSessions(
new ExperimentSessionBatchRequest(
[
new ExperimentSessionBatchItem(
projectIds[0],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null),
new ExperimentSessionBatchItem(
projectIds[1],
fixture.SecondClassroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null)
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
}
[Fact]
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
var created = await controller.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var sessionResult = await controller.CreateSession(
project.Id,
fixture.SessionRequest(1, 2, 10),
CancellationToken.None);
Assert.IsType<CreatedResult>(sessionResult);
Assert.Equal(
1,
(await fixture.Db.ExperimentSessions.SingleAsync()).Capacity);
var published = await controller.PublishProject(
project.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(published);
Assert.Equal(
ExperimentProjectStatus.Published,
(await fixture.Db.ExperimentProjects.SingleAsync()).Status);
var session = await fixture.Db.ExperimentSessions.SingleAsync();
var participants = await controller.GetParticipants(
session.Id,
CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(participants);
var rows = Assert.IsAssignableFrom<IEnumerable<object>>(ok.Value);
Assert.Single(rows);
}
[Fact]
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
project.Id,
fixture.SessionRequest(3, 2),
CancellationToken.None);
await manager.CreateSession(
project.Id,
fixture.SessionRequest(5, 2),
CancellationToken.None);
await manager.PublishProject(project.Id, CancellationToken.None);
var sessions = await fixture.Db.ExperimentSessions
.OrderBy(x => x.StartPeriod)
.ToListAsync();
var student = fixture.Controller(fixture.StudentScope);
Assert.IsType<NoContentResult>(
await student.Book(sessions[0].Id, CancellationToken.None));
Assert.IsType<ConflictObjectResult>(
await student.Book(sessions[1].Id, CancellationToken.None));
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.IsType<NoContentResult>(
await student.CancelBooking(booking.Id, CancellationToken.None));
Assert.IsType<NoContentResult>(
await student.Book(sessions[1].Id, CancellationToken.None));
var active = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.Equal(ExperimentBookingStatus.Booked, active.Status);
Assert.Equal(sessions[1].Id, active.ExperimentSessionId);
Assert.Equal(0, (await fixture.Db.ExperimentSessions.FindAsync(
sessions[0].Id))!.ReservedCount);
Assert.Equal(1, (await fixture.Db.ExperimentSessions.FindAsync(
sessions[1].Id))!.ReservedCount);
}
[Fact]
public async Task StudentBooking_RejectsPublishedTimetableConflict()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
project.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
await manager.PublishProject(project.Id, CancellationToken.None);
fixture.Db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.SecondClassroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
});
await fixture.Db.SaveChangesAsync();
var session = await fixture.Db.ExperimentSessions.SingleAsync();
var result = await fixture.Controller(fixture.StudentScope)
.Book(session.Id, CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Empty(fixture.Db.ExperimentBookings);
}
[Fact]
public async Task CentralizedSession_RejectsRelatedPublishedTimetableConflict()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
fixture.Db.SchedulePlans.Add(new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
Entries =
[
new ScheduleEntry
{
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.SecondClassroom.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 18,
WeekPattern = WeekPattern.All
}
]
});
await fixture.Db.SaveChangesAsync();
var result = await manager.CreateSession(
project.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
Assert.Empty(fixture.Db.ExperimentSessions);
}
[Fact]
public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var manager = fixture.Controller(fixture.ManagerScope);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var centralized = await fixture.Db.ExperimentProjects.SingleAsync();
await manager.CreateSession(
centralized.Id,
fixture.SessionRequest(1, 2),
CancellationToken.None);
await manager.PublishProject(centralized.Id, CancellationToken.None);
await manager.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var selfScheduled = await fixture.Db.ExperimentProjects.SingleAsync(
x => x.ArrangementMode == ExperimentArrangementMode.SelfScheduled);
await manager.CreateSession(
selfScheduled.Id,
fixture.SessionRequest(4, 2),
CancellationToken.None);
await manager.PublishProject(selfScheduled.Id, CancellationToken.None);
var selfSession = await fixture.Db.ExperimentSessions.SingleAsync(
x => x.ExperimentProjectId == selfScheduled.Id);
var studentController = fixture.Controller(fixture.StudentScope);
Assert.IsType<NoContentResult>(
await studentController.Book(
selfSession.Id,
CancellationToken.None));
var student = await fixture.Db.Students.AsNoTracking().SingleAsync();
var service = new TimetableDataService(fixture.Db);
var timetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
CancellationToken.None);
Assert.NotNull(timetable);
Assert.Equal(2, timetable.ExperimentEntries.Count);
Assert.Contains(
timetable.ExperimentEntries,
x => x.ExperimentArrangementMode ==
ExperimentArrangementMode.Centralized);
Assert.Contains(
timetable.ExperimentEntries,
x => x.Id == selfSession.Id &&
x.ExperimentArrangementMode ==
ExperimentArrangementMode.SelfScheduled);
Assert.All(
timetable.ExperimentEntries,
x =>
{
Assert.True(x.IsExperiment);
Assert.Equal(fixture.Term.StartDate, x.ExperimentDate);
Assert.Equal(1, x.StartWeek);
});
var publicClassTimetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
Assert.NotNull(publicClassTimetable);
Assert.Empty(publicClassTimetable.ExperimentEntries);
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
Assert.IsType<NoContentResult>(
await studentController.CancelBooking(
booking.Id,
CancellationToken.None));
timetable = await service.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
fixture.Term.Id,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
CancellationToken.None);
Assert.NotNull(timetable);
Assert.Single(timetable.ExperimentEntries);
Assert.Equal(
ExperimentArrangementMode.Centralized,
timetable.ExperimentEntries[0].ExperimentArrangementMode);
}
private sealed class ExperimentFixture : IAsyncDisposable
{
private ExperimentFixture(
SqliteConnection connection,
AppDbContext db,
AcademicTerm term,
TeachingTask task,
Classroom classroom,
Classroom secondClassroom,
ICurrentUserDataScope managerScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Classroom = classroom;
SecondClassroom = secondClassroom;
ManagerScope = managerScope;
StudentScope = studentScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public AcademicTerm Term { get; }
public TeachingTask Task { get; }
public Classroom Classroom { get; }
public Classroom SecondClassroom { get; }
public ICurrentUserDataScope ManagerScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentFixture> 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 manager = User("manager", "学院实验管理员");
var studentUser = User("student", "实验学生");
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
Code = "LAB",
Name = "实验中心",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "LAB101",
Name = "实验室 101",
BuildingId = building.Id,
Capacity = 40
};
var secondClassroom = new Classroom
{
Code = "LAB102",
Name = "实验室 102",
BuildingId = building.Id,
Capacity = 40
};
var college = new College { Code = "CS", Name = "计算机学院" };
manager.CollegeId = college.Id;
var major = new Major
{
Code = "CS",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2099",
Name = "计科 2099",
MajorId = major.Id,
Grade = 2099
};
var student = new Student
{
StudentNumber = "20990001",
Name = "实验学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2099,
EnrollmentDate = new DateOnly(2099, 9, 1),
UserId = studentUser.Id
};
var teacher = new Teacher
{
TeacherNumber = "T2099",
Name = "实验教师",
CollegeId = college.Id
};
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 7),
EndDate = new DateOnly(2100, 1, 17),
IsCurrent = true
};
var course = new Course
{
Code = "CSLAB",
Name = "系统实验",
CollegeId = college.Id,
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var task = new TeachingTask
{
TaskNumber = "2099-1-CSLAB-01",
Name = "系统实验教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 40,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
var secondTask = new TeachingTask
{
TaskNumber = "2099-1-CSLAB-02",
Name = "系统实验教学班 02",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 40,
Status = TeachingTaskStatus.Published
};
db.AddRange(
manager,
studentUser,
campus,
building,
classroom,
secondClassroom,
college,
major,
administrativeClass,
student,
teacher,
term,
course,
task,
secondTask);
for (var period = 1; period <= 12; period++)
{
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
{
AcademicTermId = term.Id,
PeriodNumber = period,
Name = $"第 {period} 节",
StartsAt = new TimeOnly(8, 0).AddMinutes((period - 1) * 50),
EndsAt = new TimeOnly(8, 45).AddMinutes((period - 1) * 50)
});
}
await db.SaveChangesAsync();
return new ExperimentFixture(
connection,
db,
term,
task,
classroom,
secondClassroom,
Scope(
manager,
SystemRoles.CollegeAdmin,
DataScope.College),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
public ExperimentsController Controller(
ICurrentUserDataScope currentScope) =>
new(
Db,
currentScope,
new ClassroomReservationAvailabilityService(Db));
public ExperimentProjectRequest ProjectRequest(
ExperimentArrangementMode mode) =>
new(
Task.Id,
mode == ExperimentArrangementMode.Centralized
? "LAB-C"
: "LAB-S",
mode == ExperimentArrangementMode.Centralized
? "集中上机实验"
: "自主上机实验",
mode,
"完成规定实验项目。",
"携带校园卡。",
Term.StartDate,
Term.StartDate.AddDays(14));
public ExperimentProjectBatchRequest BatchProjectRequest(
ExperimentArrangementMode mode) =>
new(
Db.TeachingTasks
.OrderBy(x => x.TaskNumber)
.Select(x => x.Id)
.ToList(),
"LAB-BATCH",
"公共实验任务",
mode,
"多个教学任务共用的实验内容。",
"携带校园卡。",
Term.StartDate,
Term.StartDate.AddDays(14));
public ExperimentSessionRequest SessionRequest(
int startPeriod,
int periodCount,
int capacity = 1) =>
new(
Classroom.Id,
Term.StartDate,
startPeriod,
periodCount,
capacity,
null);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role,
DataScope dataScope) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
dataScope,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}