grading
Version:
Grading of student submissions, in particular programming tests.
359 lines (297 loc) • 11.2 kB
text/typescript
import { Image } from "./gradingschema";
export const GRADE_VALUES = [
// 0 1 2 3 4 5 6 7 8 9 10
"5,0", "4,0", "3,7", "3,3", "3,0", "2,7", "2,3", "2,0", "1,7", "1,3", "1,0"
// 50%
]
export function gradeFromPoints(points: number, schema: number[]) {
for (let note = GRADE_VALUES.length - 1; note > 0; note--) {
if (points >= schema[note]) {
return GRADE_VALUES[note];
}
}
return GRADE_VALUES[0];
}
function gradingTable(pointsMax: number, requiredPoints?: number): number[] {
if (!requiredPoints) {
requiredPoints = Math.floor(pointsMax / 2);
}
const schema: number[] = [];
const stepsBestanden = (pointsMax - (requiredPoints - 1)) / (GRADE_VALUES.length - 1);
schema[0] = 0;
// falls schlechter als 4 (4,3, 4,7 etc); nicht an der BHT
// const stepsDurchgefallen = bestandenAb / 5;
// for (let i = 0; i < 5; i++) {
// schema[i] = Math.floor(stepsDurchgefallen * i);
// }
schema[1] = requiredPoints;
for (let i = 2; i < GRADE_VALUES.length; i++) {
schema[i] = requiredPoints + Math.floor(stepsBestanden * (i - 1));
}
return schema;
}
export class Results {
readonly studentResults: StudentResult[] = []
readonly gradingTable: number[];
readonly dateTime: number;
appliedCorrections: number = 0;
constructor(
public readonly course: string,
public readonly term: string,
public readonly examName: string,
public readonly maxPoints: number,
public readonly minCoverageStatements: number = 0,
public readonly penaltiesCoverageMax: number = 0,
public readonly extraCoverageMax: number = 0,
/**
* How many points are necessary to pass the exam.
*/
public readonly requiredPoints?: number
) {
this.gradingTable = gradingTable(maxPoints, requiredPoints);
this.dateTime = Date.now();
}
/**
*
* @param rawPoints Punkte ohne Coverage-Korrektur
* @param stmtCoverage Statement-Coverage (des Studenten)
* @returns Korrekturfaktor für Coverage; 0, wenn keine Coverage verlangt (minCoverageStatements undefined oder 0)
*/
coveragePunkte(rawPoints: number, stmtCoverage: number) {
if (this.minCoverageStatements <= 0 || this.minCoverageStatements > 100 || this.minCoverageStatements == stmtCoverage) {
return 0;
}
if (stmtCoverage < 0) {
stmtCoverage = 0;
}
if (stmtCoverage > 100) {
stmtCoverage = 100;
}
if (stmtCoverage < this.minCoverageStatements) {
const factor = Math.max(0, stmtCoverage) / this.minCoverageStatements;
// penaltiesCoverageMax = 0 means: no penalty
const deductionMax = Math.min(this.penaltiesCoverageMax, this.maxPoints);
const deduction = (1 - factor) * deductionMax;
return Math.max(-rawPoints, -Math.floor(deduction));
} else { // stmtCoverage>this.minCoverageStatements
if (this.extraCoverageMax > 0) {
const factor = (stmtCoverage - this.minCoverageStatements) / (100 - this.minCoverageStatements);
const extra = this.extraCoverageMax * factor;
return Math.min(rawPoints, Math.ceil(extra));
}
}
return 0;
}
}
class Skippable {
skipMessage: string | null = null;
skipped(): this is { skipMessage: string } {
return this.skipMessage ? true : false;
}
}
/**
* Points may be 0 if only a comment is added.
*/
export class ManualCorrection {
points: number;
reason: string|string[];
absolute: boolean;
constructor(points: number | undefined, reason: string|string[], absolute: boolean) {
if (!points) {
this.points = 0;
} else {
this.points = points;
}
this.reason = reason;
this.absolute = absolute;
}
}
export class StudentResult extends Skippable {
readonly taskResults: TaskResult[] = [];
readonly coverageResult = new CoverageResult();
noSubmission: boolean = false;
checkReportFound = false;
studentReportFound = false;
gradingDone = false;
readonly penaltyResults: GradingResult[] = []
manualCorrection: ManualCorrection[] = [];
errorDetails: string = "";
/**
* sum of points of tasks including manual corrections, computed in computeAndSetRawPunkte
*/
pointsRaw: number = 0;
/**
* actual coverage points, can only computed when rawPoints have been set
*/
pointsCoverage: number = 0;
/**
* points raw + point coverage
*/
points: number = 0;
/**
* grade (1-5, no 4,3 and 4,7), can only be computed when points have been set.
*/
grade: string = "";
/**
* Set to true if manual correction changed the points using absolute points.
*/
absolutePoints = false;
userTests: boolean = false;
userTestsSuccess: number = 0;
userTestsFailureOrError: number = 0;
userTestsCount: number = 0;
comment ="";
patched = false;
constructor(public readonly submissionId: string, public readonly userName: string) {
super();
}
addComment(comment: string) {
if (this.comment.length > 0) {
this.comment += " ";
}
this.comment += comment;
}
addPenalty(gradingResult: GradingResult) {
this.penaltyResults.push(gradingResult);
}
/**
* @returns Returns true, if any penalities exist (i.e. taking actual results into account).
*/
hasPenalties() {
let penaltyPoints = this.penaltyResults.reduce((sum, bew) => sum + bew.points, 0);
return penaltyPoints != 0
}
computeAndSetRawPunkte() {
this.pointsRaw = this.taskResults.reduce(
(sum, taskResult) => {
// compute pointsRaw for each task even if it skipped as it may contain manual corrections
taskResult.computeAndSetRawPoints();
return sum + taskResult.points;
}, 0)
if (this.penaltyResults.length > 0) {
this.penaltyResults.forEach(gradingResult => gradingResult.computeAndSetRawPoints());
this.pointsRaw += this.penaltyResults.reduce((sum, gradingResult) => sum + gradingResult.points, 0)
}
this.manualCorrection.forEach(manualCorrection => this.pointsRaw += manualCorrection.points);
if (this.pointsRaw < 0) {
this.pointsRaw = 0;
}
}
get pointsMax() {
return this.taskResults.reduce(
(sum, task) => sum + task.pointsMax, 0
)
}
}
export class CoverageResult extends Skippable {
public stmtCoverage: number = 0;
constructor() {
super();
}
}
type ConditionStatus = "none" | "failed" | "success" | "notFound";
export class TaskResult extends Skippable {
readonly name: string;
readonly pointsMax: number
readonly gradingResults: GradingResult[] = []
readonly penaltyResults: GradingResult[] = []
points = 0;
manualCorrections: ManualCorrection[] = [];
images: Image[] = [];
message: string | null = null;
/** for statistics */
precondition: ConditionStatus = "none";
constructor(name: string, pointsMax: number) {
super();
this.name = name;
this.pointsMax = pointsMax;
}
addGradingResult(gradingResult: GradingResult) {
this.gradingResults.push(gradingResult);
}
addPenalty(gradingResult: GradingResult) {
this.penaltyResults.push(gradingResult);
}
/**
* @returns Returns true, if any penalities exist (i.e. taking actual results into account).
*/
hasPenalties() {
let penaltyPoints = this.penaltyResults.reduce((sum, bew) => sum + bew.points, 0);
return penaltyPoints != 0
}
computeAndSetRawPoints() {
this.points = 0;
if (!this.skipped()) {
if (this.gradingResults.length > 0) {
this.gradingResults.forEach(gradingResult => gradingResult.computeAndSetRawPoints());
this.points = this.gradingResults.reduce((sum, gradingResult) => sum + gradingResult.points, 0);
}
if (this.penaltyResults.length > 0) {
this.penaltyResults.forEach(gradingResult => gradingResult.computeAndSetRawPoints());
this.points += this.penaltyResults.reduce((sum, gradingResult) => sum + gradingResult.points, 0)
}
}
this.manualCorrections.forEach(correction => this.points += correction.points);
if (this.points < 0) { // minimum 0 points per task
this.points = 0;
}
}
}
export class GradingResult extends Skippable {
readonly testResults: TestResult[] = []
points = 0;
precondition?: string;
images: Image[] = []
message: string | null = null;
/**
* If the grading is negative (i.e., {@link pointsMax} is less 0), the grading is a penalty.
*/
constructor(public readonly text: string, public readonly pointsMax: number, public isBonus?: boolean) {
super();
}
addTestResult(text: string, testStatus: TestStatus, message: string | undefined) {
this.testResults.push(new TestResult(text, testStatus, message));
}
/**
* If the grading is negative (i.e., {@link pointsMax} is less 0), the grading is a penalty.
*/
get isPenalty() {
return this.pointsMax < 0;
}
/**
* Computes raw points (and sets them internally to field {@link points}).
*
* If the grading is positive, the number of successfully passed tests is used to compute the raw points.
* If the grading is a penalty (i.e. grading is negative) the number of failed tests are used instead.
*/
computeAndSetRawPoints() {
this.points = 0;
if (!this.isPenalty) {
if (this.skipped()) {
return;
}
const successCount = this.testResults.filter(test => test.testStatus === "success").length;
if (this.testResults.length == 0) {
console.error("Keine Testergebnisse verzeichnet, setze Punkte für Bewertung " + this.text + " auf 0.")
this.points = 0;
} else {
this.points = Math.ceil(this.pointsMax * successCount / this.testResults.length);
}
} else {
const failureCount = this.testResults.filter(test => test.testStatus !== "success").length;
if (this.testResults.length == 0) {
this.points = 0;
} else {
this.points = Math.floor(this.pointsMax * failureCount / this.testResults.length);
}
}
}
}
export type TestStatus = "success" | "failure" | "error" | "skipped" | "notFound";
export class TestResult {
constructor(
public text: string,
public testStatus: TestStatus,
public message: string | undefined) {
}
}