Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs
T
biss c26e082a77 修复实验成绩管理:
管理列表改为服务端分页,支持 10/20/50 条切换。
增加学期、成绩状态、开课学院以及项目/课程/教学班/教师关键词筛选。
数据范围分级:校级管理员可查看全校,院级管理员仅限本学院,教师仅限本人任课项目。
修复任课教师提交审核时报“无权限”:工作流查询现在会加载任课教师关系。
增加权限、学院隔离、分页和筛选回归测试。
2026-08-09 21:48:19 +08:00

613 lines
22 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 Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExperimentGradesControllerTests
{
[Fact]
public async Task AssignedTeacher_CanSubmitExperimentGradeSheet()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-TEACHER",
Name = "教师提交实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.ExperimentProjects.Add(project);
await fixture.Db.SaveChangesAsync();
var admin = fixture.ExperimentGrades(fixture.AdminScope);
await admin.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
100)]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.SingleAsync();
var record = Assert.Single(sheet.Records);
var item = Assert.Single(sheet.Items);
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
Assert.IsType<NoContentResult>(await teacher.UpdateRecords(
sheet.Id,
new ExperimentGradeRecordsRequest(
[
new ExperimentGradeRecordRequest(
record.Id,
ExperimentParticipationStatus.Completed,
false,
1,
null,
null,
false,
null,
[new ExperimentGradeItemScoreRequest(item.Id, 85, null)])
]),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await teacher.Submit(
sheet.Id,
CancellationToken.None));
}
[Fact]
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var collegeId = await fixture.Db.Courses
.Where(x => x.Id == fixture.Task.CourseId)
.Select(x => x.CollegeId)
.SingleAsync();
for (var index = 1; index <= 12; index++)
{
fixture.Db.ExperimentProjects.Add(new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = $"LAB-{index:00}",
Name = $"分页实验 {index:00}",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
});
}
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
var otherCourse = new Course
{
CollegeId = otherCollege.Id,
Code = "OTHER-LAB",
Name = "其他学院实验",
Credits = 1,
TotalHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var otherTask = new TeachingTask
{
AcademicTermId = fixture.Term.Id,
CourseId = otherCourse.Id,
TaskNumber = "OTHER-LAB-01",
Name = "其他学院实验班",
Capacity = 20,
Status = TeachingTaskStatus.Published
};
var otherProject = new ExperimentProject
{
TeachingTaskId = otherTask.Id,
Code = "FOREIGN-LAB",
Name = "不可见实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.AddRange(otherCollege, otherCourse, otherTask, otherProject);
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var collegeScope = new FixedScope(new CurrentUserScope(
Guid.NewGuid(),
"学院管理员",
collegeId,
DataScope.College,
new HashSet<string>([SystemRoles.CollegeAdmin])));
var controller = fixture.ExperimentGrades(collegeScope);
var page = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null,
null,
null,
null,
1,
10,
CancellationToken.None));
Assert.Equal(12, Property<int>(page.Value, "Total"));
Assert.Equal(10, Property<System.Collections.IEnumerable>(
page.Value,
"Items").Cast<object>().Count());
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null,
null,
null,
"LAB-12",
1,
10,
CancellationToken.None));
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
}
private static T Property<T>(object? value, string name) =>
Assert.IsAssignableFrom<T>(
value!.GetType().GetProperty(name)!.GetValue(value));
[Fact]
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
fixture.Db.ExperimentProjects.Add(new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-C",
Name = "集中实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
});
await fixture.Db.SaveChangesAsync();
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var experimentGrades = fixture.ExperimentGrades(fixture.AdminScope);
var created = await experimentGrades.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
50),
new ExperimentGradeItemRequest(
"报告",
ExperimentGradeItemKind.Report,
50)
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var sheet = await fixture.Db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.SingleAsync();
var record = Assert.Single(sheet.Records);
var operation = sheet.Items.Single(x => x.Name == "操作");
var report = sheet.Items.Single(x => x.Name == "报告");
Assert.IsType<NoContentResult>(
await experimentGrades.UpdateRecords(
sheet.Id,
new ExperimentGradeRecordsRequest(
[
new ExperimentGradeRecordRequest(
record.Id,
ExperimentParticipationStatus.Completed,
false,
1,
"https://example.test/report",
DateTime.UtcNow,
false,
"完成规范",
[
new ExperimentGradeItemScoreRequest(
operation.Id,
80,
"操作正确"),
new ExperimentGradeItemScoreRequest(
report.Id,
90,
"报告完整")
])
]),
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Submit(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Approve(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Publish(
sheet.Id,
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var publishedRecord = await fixture.Db.ExperimentGradeRecords
.SingleAsync();
Assert.Equal(85m, publishedRecord.TotalScore);
Assert.True(publishedRecord.IsPassed);
Assert.Equal(
ExperimentGradeSheetStatus.Published,
(await fixture.Db.ExperimentGradeSheets.SingleAsync()).Status);
var studentResult = await fixture
.ExperimentGrades(fixture.StudentScope)
.GetMine(CancellationToken.None);
Assert.IsType<OkObjectResult>(studentResult);
var courseSheet = new GradeSheet
{
TeachingTaskId = fixture.Task.Id,
RegularWeight = 0,
FinalWeight = 50,
Items =
[
new GradeItem
{
Name = "实验",
Weight = 50,
SortOrder = 0
}
],
Records =
[
new GradeRecord
{
StudentId = fixture.Student.Id,
FinalScore = 80
}
]
};
var courseItem = courseSheet.Items.Single();
courseSheet.Records.Single().ItemScores.Add(new GradeItemScore
{
GradeItemId = courseItem.Id
});
fixture.Db.GradeSheets.Add(courseSheet);
await fixture.Db.SaveChangesAsync();
var grades = new GradesController(fixture.Db, fixture.AdminScope);
Assert.IsType<OkObjectResult>(
await grades.ImportExperimentScores(
courseSheet.Id,
new ImportExperimentScoresRequest(courseItem.Id),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var importedItem = await fixture.Db.GradeItems.SingleAsync();
var importedRecord = await fixture.Db.GradeRecords
.Include(x => x.ItemScores)
.SingleAsync();
Assert.Equal(
GradeItemSourceType.ExperimentSummary,
importedItem.SourceType);
Assert.NotNull(importedItem.SourceSnapshotAt);
Assert.Equal(85m, importedRecord.ItemScores.Single().Score);
Assert.Equal(82.5m, importedRecord.TotalScore);
var manualOverride = await grades.UpdateRecords(
courseSheet.Id,
new GradeRecordsRequest(
[
new GradeRecordRequest(
importedRecord.Id,
null,
80,
[new GradeItemScoreRequest(importedItem.Id, 90)],
GradeExamStatus.Normal,
null)
]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(manualOverride);
}
[Fact]
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-S",
Name = "自主实验",
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = fixture.Classroom.Id,
SessionDate = fixture.Term.StartDate,
StartPeriod = 1,
PeriodCount = 2,
Capacity = 10,
ReservedCount = 1
};
var booking = new ExperimentBooking
{
ExperimentProjectId = project.Id,
ExperimentSessionId = session.Id,
StudentId = fixture.Student.Id,
Status = ExperimentBookingStatus.Booked,
BookedAt = DateTime.UtcNow
};
fixture.Db.AddRange(project, session, booking);
await fixture.Db.SaveChangesAsync();
var controller = fixture.ExperimentGrades(fixture.AdminScope);
await controller.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
100)
]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
Assert.Single(fixture.Db.ExperimentGradeRecords);
booking.Status = ExperimentBookingStatus.Cancelled;
booking.CancelledAt = DateTime.UtcNow;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Empty(fixture.Db.ExperimentGradeRecords);
fixture.Db.ChangeTracker.Clear();
booking = await fixture.Db.ExperimentBookings.SingleAsync();
controller = fixture.ExperimentGrades(fixture.AdminScope);
booking.Status = ExperimentBookingStatus.Booked;
booking.CancelledAt = null;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Single(fixture.Db.ExperimentGradeRecords);
}
private sealed class ExperimentGradeFixture : IAsyncDisposable
{
private ExperimentGradeFixture(
SqliteConnection connection,
AppDbContext db,
AcademicTerm term,
TeachingTask task,
Student student,
Classroom classroom,
ICurrentUserDataScope adminScope,
ICurrentUserDataScope teacherScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Student = student;
Classroom = classroom;
AdminScope = adminScope;
TeacherScope = teacherScope;
StudentScope = studentScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public AcademicTerm Term { get; }
public TeachingTask Task { get; }
public Student Student { get; }
public Classroom Classroom { get; }
public ICurrentUserDataScope AdminScope { get; }
public ICurrentUserDataScope TeacherScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentGradeFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var admin = User("lab-admin", "实验管理员");
var studentUser = User("lab-student", "实验学生");
var teacherUser = User("lab-teacher", "实验教师");
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
CampusId = campus.Id,
Code = "LAB",
Name = "实验楼"
};
var classroom = new Classroom
{
BuildingId = building.Id,
Code = "LAB201",
Name = "实验室 201",
Capacity = 30
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
CollegeId = college.Id,
Code = "CS",
Name = "计算机科学与技术",
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
MajorId = major.Id,
Code = "CS2099",
Name = "计科 2099",
Grade = 2099
};
var student = new Student
{
UserId = studentUser.Id,
AdministrativeClassId = administrativeClass.Id,
StudentNumber = "20990001",
Name = "实验学生",
EnrollmentYear = 2099,
EnrollmentDate = new DateOnly(2099, 9, 1)
};
var teacher = new Teacher
{
UserId = teacherUser.Id,
CollegeId = college.Id,
TeacherNumber = "T2099",
Name = "实验教师"
};
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
{
CollegeId = college.Id,
Code = "CSLAB",
Name = "系统实验",
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var task = new TeachingTask
{
AcademicTermId = term.Id,
CourseId = course.Id,
TaskNumber = "2099-1-CSLAB-01",
Name = "系统实验教学班",
Capacity = 30,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
admin,
studentUser,
teacherUser,
campus,
building,
classroom,
college,
major,
administrativeClass,
student,
teacher,
term,
course,
task);
await db.SaveChangesAsync();
return new ExperimentGradeFixture(
connection,
db,
term,
task,
student,
classroom,
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
Scope(teacherUser, SystemRoles.Teacher, DataScope.Self),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
public ExperimentGradesController ExperimentGrades(
ICurrentUserDataScope scope) => new(Db, scope);
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;
}
}