Files
Academic-Affairs-System/tests/Jiaowu.Api.Tests/ExperimentGradesControllerTests.cs
T
biss 8f56548b54 已按“实验成绩独立管理”完成修改。
每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
自主预约实验支持同步有效预约名单,已评分记录不会被误删。
课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
2026-07-28 22:21:18 +08:00

455 lines
16 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 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 studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Student = student;
Classroom = classroom;
AdminScope = adminScope;
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 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(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;
}
}