grading
Version:
Grading of student submissions, in particular programming tests.
799 lines (705 loc) • 33.8 kB
text/typescript
import { OptionValues } from "commander";
import { parse } from "comment-json";
import { CellFormulaValue, CellIsRuleType, ExpressionRuleType } from "exceljs";
import fs from "fs";
import path from "path";
import { loadTableWithEncoding, parseGrading, writeTableWithEncoding } from "../csv";
import { asNumber, bgLightGray, bgLightGreen, bgLightRed, bgLightYellow, bgVeryLightGray, forEachCellInRow, headerRow, toCol, writeExcel } from "../excel";
import { EXT_EXCEL, createDir, fileExists, folderExists, readDir } from "../fsUtil";
import { ManualConclusion } from "../grade/manualconclusion";
import { Table } from "../table";
import { error, generateFileName, log, verb, verbosity, warn } from "./cliUtil";
import { compareNumberAware } from "./compareNumberAware";
import { copyFile, rename, rm } from "fs/promises";
type Fields<T> = {
[Property in keyof T as T[Property] extends Function ? never : Property]: T[Property]
};
export class ExamResult {
submissionID: string;
name: string;
exam: string;
submitted: boolean;
grade: number;
points: number;
constructor(submissionID: string, name: string, exam: string | undefined, submitted: boolean, grade: number, points: number) {
if (!submissionID || !name || !exam || grade < 0 || points < 0) {
throw new Error(`Invalid exam result: SubmissionID ${submissionID}, name ${name}, exam ${exam}, submitted ${submitted}, grade ${grade}, points ${points}`);
}
this.submissionID = submissionID;
this.name = name;
this.exam = exam;
this.submitted = submitted;
this.grade = grade;
this.points = points;
}
}
export class AllExamResults {
examsByStudent: Map<string, ExamResult[]> = new Map();
addExamResult(examResult: ExamResult) {
let examResults = this.examsByStudent.get(examResult.name);
if (!examResults) {
examResults = [];
this.examsByStudent.set(examResult.name, examResults);
}
if (examResults.some(er => er.exam === examResult.exam)) {
throw new Error(`Duplicate exam result found for ${examResult.name} in ${examResult.exam} -- does the folder contain multiple results for same exam?`);
}
examResults.push(examResult);
}
}
// export for tests
export interface ExamGrade {
exam: string;
grade: number;
selected: boolean;
}
// export for tests
export class Conclusion {
name: string;
submissions: number;
/**
* Number of submissions with grade >= 4
*/
successful: number;
/**
* Number of submissions failed (grade > 4) in
* range [0..totalExams]
*/
failed: number;
/**
* Number of submissions missing to compute the final grade
* in range [0..selectBest]
* There may be more submissions missing, but they are not counted.
*/
missing: number;
/**
* Actually submitted exams with their grade.
*/
examGrades: ExamGrade[];
/**
* Average grade of all submitted exams as string,
* empty string if no exams submitted.
*/
averageGrade: string;
maxGrade: string;
finalGrade: string;
minGrade: string;
remark?: string;
constructor(src: Fields<Conclusion>) {
this.name = src.name;
this.submissions = src.submissions;
this.successful = src.successful;
this.failed = src.failed;
this.missing = src.missing;
this.examGrades = src.examGrades;
this.averageGrade = src.averageGrade;
this.finalGrade = src.finalGrade;
this.minGrade = src.minGrade;
this.maxGrade = src.maxGrade;
this.remark = src.remark;
}
getFinalGradeAsNumber(): number {
return parseGrading(this.finalGrade);
}
getGradeOrNA() {
}
}
export async function cmdConclude(options: OptionValues, runInternally = false) {
verbosity(options);
const resultsDir: string = options.resultsDir;
const resultFile: string = options.resultFile;
const encoding: string = options.encoding;
const resultCSVDelimiter: string = options.resultCSVDelimiter;
const conclusionFile: string = options.conclusionFile;
const selectBest: number = options.selectBest;
const totalExams: number = options.totalExams;
const maxFailed: number = options.maxFailed;
const deprecatedResultsDir: string = options.deprecatedResultsDir;
const autoCopyResult: boolean = options.autoCopyResult;
const autoCopyConclusion: boolean = options.autoCopyConclusion;
const keepOldResult: boolean = options.keepOldResult;
const noFinalGradeBefore: string = options.noFinalGradeBefore;
try {
const allResultCSVs = await retrieveResultCSVFilenames(resultsDir);
if (autoCopyResult) {
await doAutoCopyResult(resultFile, resultsDir, allResultCSVs, keepOldResult, deprecatedResultsDir);
} else {
verb(`Auto copy results disabled.`);
}
if (allResultCSVs.length == 0) {
error(`No result CSV files found in ${resultsDir}`);
return;
}
const manualConclusion = await readManualConclusion(options);
checkManualConclusion(manualConclusion);
const allExamResults = new AllExamResults();
verb('Reading result CSVs...')
await readAllResults(allResultCSVs, resultsDir, encoding, resultCSVDelimiter, allExamResults);
normalizeAliases(manualConclusion, allExamResults);
verb('Calculating conclusions...');
// Collect all submissions
const { conclusions, examTitles, examTitlesWithUsage } = calculateConclusions(allExamResults, totalExams, selectBest, maxFailed, noFinalGradeBefore, manualConclusion);
// Create table
const conclusionFileName = await createAndWriteConclusionTable(examTitlesWithUsage, conclusions, examTitles, conclusionFile, selectBest, maxFailed, totalExams, encoding, resultCSVDelimiter);
if (autoCopyConclusion) {
const resultWithConclusion = path.join(resultsDir, path.basename(conclusionFileName));
log(`Copy ${conclusionFileName} to ${resultWithConclusion}`);
await copyFile(conclusionFileName, resultWithConclusion);
} else {
verb(`Auto copy conclusion disabled.`);
}
} catch (err) {
error(err);
program.error(String(err));
}
if (!runInternally) {
log(`${SEP}\nDone`);
}
}
async function createAndWriteConclusionTable(examTitlesWithUsage: string[], conclusions: Conclusion[], examTitles: string[], conclusionFile: string, selectBest: number, maxFailed: number, totalExams: number, encoding: string, resultCSVDelimiter: string) {
const conclusionTable = new Table();
conclusionTable.addRow('Name', 'Final Grade', 'Submissions', 'Success', 'Missing', 'Failed', 'Avg', 'Min', 'Max', ...examTitlesWithUsage, "Calc", "Kommentar");
for (const conclusion of conclusions) {
conclusionTable.addRow(
conclusion.name,
conclusion.finalGrade,
conclusion.submissions,
conclusion.successful,
conclusion.missing,
conclusion.failed,
conclusion.averageGrade,
conclusion.minGrade,
conclusion.maxGrade,
...usedExams(examTitles, conclusion),
'',
conclusion.remark || ''
);
}
const firstExam = examTitles[0];
const lastExam = examTitles.length > 0 ? examTitles[examTitles.length - 1] : undefined;
const conclusionFileName = generateFileName(conclusionFile, { dateTime: new Date().getTime(), firstExam, lastExam });
if (conclusionFileName.endsWith(EXT_EXCEL)) {
await writeTableAsExcel(conclusionFileName, conclusionTable, selectBest, maxFailed, totalExams);
} else {
await writeTableWithEncoding(conclusionFileName, conclusionTable, encoding, resultCSVDelimiter);
}
log(`Wrote conclusion to ${conclusionFileName}`);
return conclusionFileName;
}
export function calculateConclusions(allExamResults: AllExamResults, totalExams: number, selectBest: number, maxFailed: number, noFinalGradeBefore: string, manualConclusion: ManualConclusion | undefined):
{ examTitlesWithUsage: string[]; conclusions: Conclusion[]; examTitles: string[]; } {
const examTitlesSet = new Set<string>();
const conclusions: Conclusion[] = [];
let maxSubmissions = 0;
// 1. Creates a conclusion with exam grades for each student. Only submitted exames are added here and no grade is selected yet.
for (const examResults of allExamResults.examsByStudent.values()) {
const conclusion = new Conclusion({
name: examResults[0].name,
submissions: 0,
successful: 0,
missing: 0,
failed: 0,
examGrades: [],
averageGrade: "",
finalGrade: "",
minGrade: "",
maxGrade: "",
});
for (const examResult of examResults) {
if (examResult.submitted) {
examTitlesSet.add(examResult.exam);
conclusion.submissions++;
maxSubmissions = Math.max(maxSubmissions, conclusion.submissions);
conclusion.examGrades.push({ exam: examResult.exam, grade: examResult.grade, selected: false });
}
}
conclusions.push(conclusion);
}
// Actually compute the conclusion values:
for (const conclusion of conclusions) {
computeConclusionValues(conclusion, maxSubmissions, totalExams, selectBest, maxFailed, noFinalGradeBefore);
}
const examTitles = Array.from(examTitlesSet).sort(compareNumberAware);
const examTitlesWithUsage = examTitles.map(title => [title, "x"]).flat();
conclusions.sort((a, b) => a.name.localeCompare(b.name));
const appliedManualConclusions: string[] = [];
if (manualConclusion) {
manualConclusion.conclusions.forEach(manualConclusion => {
let conclusion = conclusions.find(conclusion => conclusion.name === manualConclusion.userName);
if (!conclusion) {
verb(`Manual conclusion for ${manualConclusion.userName} not found in computed results, create new one (probably grading from previous term).`);
conclusion = new Conclusion({
name: manualConclusion.userName,
submissions: 0,
successful: 0,
missing: 0,
failed: 0,
examGrades: [],
averageGrade: "",
finalGrade: "",
minGrade: "",
maxGrade: "",
});
conclusions.push(conclusion);
}
if (manualConclusion.totalGrading) {
conclusion.finalGrade = toNumWithComma(manualConclusion.totalGrading);
conclusion.remark = manualConclusion.gradingReason;
appliedManualConclusions.push(`${manualConclusion.userName} (${conclusion.finalGrade})`);
}
if (manualConclusion.generalRemark) {
if (conclusion.remark) {
conclusion.remark += ". " + manualConclusion.generalRemark;
} else {
conclusion.remark = manualConclusion.generalRemark;
}
}
});
}
verb(`Found ${conclusions.length} students.`);
if (appliedManualConclusions.length > 0) {
log('Applied manual grading for: ' + appliedManualConclusions.join(', '));
}
verb("Max. number of submissions per student: " + maxSubmissions);
verb("Number of different exams found: " + examTitles.length);
verb("Number of total exams: " + totalExams);
if (maxSubmissions !== examTitles.length) {
warn(`Number of different exams (${examTitles.length}) differs from max. number of submissions (${maxSubmissions}).`);
}
return { examTitlesWithUsage, conclusions, examTitles };
}
export function normalizeAliases(manualConclusion: ManualConclusion | undefined, allExamResults: AllExamResults) {
if (manualConclusion?.aliases) {
for (const alias of manualConclusion.aliases) {
const currentName = alias[0];
const currentResults = allExamResults.examsByStudent.get(currentName);
for (let i = 1; i < alias.length; i++) {
const aliasName = alias[i];
const aliasResults = allExamResults.examsByStudent.get(aliasName);
if (aliasResults) {
for (const examResult of aliasResults) {
examResult.name = currentName;
}
allExamResults.examsByStudent.delete(aliasName);
if (!currentResults) {
allExamResults.examsByStudent.set(currentName, aliasResults);
} else {
for (const aliasResult of aliasResults) {
const currentResult = currentResults.find(cr => cr.exam === aliasResult.exam);
if (currentResult) {
if (currentResult.grade === aliasResult.grade) {
warn(`Alias ${aliasName} has same grade for ${aliasResult.exam} as ${currentName}, skipping.`);
} else {
throw new Error(`Alias ${aliasName} has different grade for ${aliasResult.exam} as ${currentName}.`);
}
} else {
currentResults.push(aliasResult);
log(`${aliasResult.exam} submitted as ${aliasName}, merged to ${currentName}.`);
}
}
}
}
}
}
}
}
async function doAutoCopyResult(resultFile: string, resultsDir: string, allResultCSVs: string[], keepOldResult: boolean, deprecatedResultsDir: string) {
const currentResultDir = path.dirname(generateFileName(resultFile, {})); // may not be complete, we only need parent
if (resultsDir === currentResultDir) {
verb(`Auto copy results disabled, resultsDir and currentResultDir are the same.`);
} else {
const currentResultCSVs = (await readDir(currentResultDir, 'file', false, 'Current results folder.'))
.filter(entry => {
const lower = entry.toLowerCase();
return lower.startsWith('results') && lower.endsWith('csv');
})
.sort((a, b) => a.localeCompare(b));
const latestResultCSVs = currentResultCSVs[currentResultCSVs.length - 1];
if (latestResultCSVs) {
const latestResultCSVBase = path.basename(latestResultCSVs);
if (allResultCSVs.some(resultFile => resultFile === latestResultCSVBase)) {
log(`Latest result CSV ${latestResultCSVBase} already in ${resultsDir}, skipping auto copy.`);
} else {
if (keepOldResult) {
const latestExamFromFilename = parseExamFromFilename(latestResultCSVBase);
const similarExamResultFile = allResultCSVs.find(resultFile => {
const examFromFilename = parseExamFromFilename(resultFile);
return examFromFilename === latestExamFromFilename;
});
if (similarExamResultFile) {
const similarExamResultCSV = path.join(resultsDir, similarExamResultFile);
const deprecatedResultsFolder = generateFileName(deprecatedResultsDir, { resultsDir });
const target = path.join(deprecatedResultsFolder, similarExamResultFile);
log(`Move old result file for exam ${latestExamFromFilename} from '${similarExamResultCSV}' to '${target}'`);
if (!await folderExists(deprecatedResultsFolder, "Deprecated results folder.")) {
await createDir(deprecatedResultsFolder);
}
await copyFile(similarExamResultCSV, target);
await rm(similarExamResultCSV);
const index = allResultCSVs.findIndex(resultFile => resultFile === similarExamResultFile);
allResultCSVs.splice(index, 1);
} else {
verb(`No other exam file for ${latestExamFromFilename} found in ${resultsDir}`);
}
}
const absCurrentResult = path.join(currentResultDir, latestResultCSVs);
const absAllResultCurrent = path.join(resultsDir, latestResultCSVs);
log(`Copy ${absCurrentResult} to ${absAllResultCurrent}`);
await copyFile(absCurrentResult, absAllResultCurrent);
allResultCSVs.push(latestResultCSVs);
allResultCSVs.sort((a, b) => a.localeCompare(b));
}
} else {
verb(`No latest result CSV found in ${currentResultDir}`);
}
}
}
export async function readAllResults(allResultCSVs: string[], resultsDir: string, encoding: string, resultCSVDelimiter: string, allExamResults: AllExamResults) {
const resultsForExam: Map<string, string> = new Map();
for (const resultCSV of allResultCSVs) {
try {
const fullName = path.join(resultsDir, resultCSV);
verb(` Reading result CSV '${fullName}'...`);
const results = await loadTableWithEncoding(fullName, encoding, resultCSVDelimiter);
const colSubmissionID = results.getColForTitles('submissionID', 'userID');
const colName = results.getColForTitles('name');
const colSubmissionFlag = results.getColForTitles('abgabe', "submitted");
const colGrade = results.getColForTitles('note', "grade");
const colPoints = results.getColForTitles('punkte', "points");
let colExam = -1;
const examFromFilename = parseExamFromFilename(resultCSV);
try {
colExam = results.getColForTitles('exam');
} catch (err) {
if (!examFromFilename) {
throw Error(`No exam column found in ${resultCSV} and no exam could be parsed from filename.`);
}
}
if (examFromFilename !== undefined && resultsForExam.has(examFromFilename)) {
throw new Error(`Duplicate result file for exam '${examFromFilename}': ${resultCSV} and ${resultsForExam.get(examFromFilename)}.`);
}
if (colSubmissionID < 0) throw new Error(`No column 'submissionID' found in ${resultCSV}`);
if (colName < 0) throw new Error(`No column 'name' found in ${resultCSV}`);
if (colSubmissionFlag < 0) throw new Error(`No column 'abgabe' found in ${resultCSV}`);
if (colGrade < 0) throw new Error(`No column 'note' found in ${resultCSV}`);
if (colPoints < 0) throw new Error(`No column 'punkte' found in ${resultCSV}`);
for (let row = 2; row <= results.rowsCount; row++) {
const submissionID = results.getText(colSubmissionID, row);
const name = results.getText(colName, row);
const submitted = results.getText(colSubmissionFlag, row) == '1';
const grade = parseGrading(results.getText(colGrade, row));
const points = parseGrading(results.getText(colPoints, row));
const exam = colExam > 0 ? results.getText(colExam, row) : examFromFilename;
const examResult = new ExamResult(submissionID, name, exam, submitted, grade, points);
allExamResults.addExamResult(examResult);
}
} catch (err) {
error(`Error reading result CSV '${resultCSV}'`);
error(err);
}
};
}
export async function retrieveResultCSVFilenames(resultsDir: string): Promise<string[]> {
return (await readDir(resultsDir, 'file', false, 'All results folder.'))
.filter(entry => {
const lower = entry.toLowerCase();
return lower.startsWith('results') && lower.endsWith('csv');
})
.sort((a, b) => a.localeCompare(b));
}
/**
* Computes conclusion values for a student.
*
* @param conclusion The current conclusion to fill
* @param maxSubmissions the max submissions for any student, i.e. the currently processed exams
* @param totalExames the number of exams in total
* @param selectBest number of submissions to select for final grade
* @param maxFailed number of submissions failed (or not submitted) before to set final grade to 5,0
*/
export function computeConclusionValues(conclusion: Conclusion,
maxSubmissions: number, totalExames: number,
selectBest: number, maxFailed: number, noFinalGradeBefore: string) {
// similar for all students, computed here for better readability (instead of passing args)
const openSubmissions = totalExames - maxSubmissions;
const surlyCounted = Math.max(0, selectBest - openSubmissions);
// sort ascending
conclusion.examGrades.sort((a, b) => a.grade - b.grade);
let used = 0;
let subSum = 0;
let minSum = 0;
let doFinalGrade = false;
conclusion.examGrades.forEach(grade => {
if (grade.exam >= noFinalGradeBefore) {
doFinalGrade = true;
}
if (used < selectBest) { // we still need grades
grade.selected = true;
subSum += grade.grade;
used++;
if (used <= surlyCounted) {
minSum += grade.grade;
}
};
if (grade.grade > 4) {
conclusion.failed++;
} else {
conclusion.successful++;
}
});
const submitted = conclusion.examGrades.length;
conclusion.missing = selectBest - used; // missing submissions for this student, this may include past submissions
conclusion.failed += maxSubmissions - submitted;
// see above: surlyCountedInCourse = selectBest - openSubmissionsInCourse;
const averageGrade = used == 0 ? 0 : subSum / used;
const maxGrade = (subSum + (5 * conclusion.missing)) / selectBest;
const finalGrade = (totalExames <= maxSubmissions) ? maxGrade : 0;
const notSubmittedButCounting = Math.max(0, surlyCounted - submitted);
const minGrade = (
minSum
+ (5 * notSubmittedButCounting) // surlyCountedInCourse!
+ (1 * Math.min(openSubmissions, selectBest - surlyCounted))
) / selectBest;
conclusion.averageGrade = averageGrade > 0 ? roundToGrade(averageGrade) : "";
conclusion.maxGrade = roundToGrade(maxGrade);
conclusion.minGrade = roundToGrade(minGrade);
if (!doFinalGrade) {
conclusion.finalGrade = "-";
} else {
conclusion.finalGrade = finalGrade > 0 ? roundToGrade(finalGrade) : "";
if (conclusion.finalGrade && conclusion.minGrade !== conclusion.maxGrade) {
warn(`Final grade is set, but min and max grade differ for ${conclusion.name}`);
}
if (!conclusion.finalGrade && conclusion.minGrade === conclusion.maxGrade) {
conclusion.finalGrade = conclusion.minGrade;
}
if (maxFailed >= 0 && conclusion.failed > maxFailed) {
conclusion.finalGrade = "5,0";
};
}
}
function parseExamFromFilename(filename: string): string | undefined {
const match = filename.match(/results_(.+)_.*\.csv/i);
if (!match) {
return undefined
}
return match[1];
}
/**
* RSPO 2016:
* Bei der Mittelung von Noten erfolgt nach einer arithmetischen Berechnung eine Rundung,
* indem die nächstgelegene Note vergeben wird. Ergibt sich bei der Mittelung ein Zahlenwert,
* der genau zwischen zwei Notenstufen liegt, so ist die bessere Note zu vergeben.
*
*/
function roundToGrade(averageGrade: number): string {
// 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%
// ]
if (averageGrade > (4.3 + 4.0) / 2) return "5,0";
if (averageGrade > (4.0 + 3.7) / 2) return "4,0";
if (averageGrade > (3.7 + 3.3) / 2) return "3,7";
if (averageGrade > (3.3 + 3.0) / 2) return "3,3";
if (averageGrade > (3.0 + 2.7) / 2) return "3,0";
if (averageGrade > (2.7 + 2.3) / 2) return "2,7";
if (averageGrade > (2.3 + 2.0) / 2) return "2,3";
if (averageGrade > (2.0 + 1.7) / 2) return "2,0";
if (averageGrade > (1.7 + 1.3) / 2) return "1,7";
if (averageGrade > (1.3 + 1.0) / 2) return "1,3";
return "1,0";
}
function usedExams(examTitles: string[], conclusion: Conclusion): (string | number)[] {
let usedExams = 0;
let selectedGrades = new Set<ExamGrade>();
const examsWithUsage: (string | number)[] = [];
for (const examTitle of examTitles) {
const grade = conclusion.examGrades.find(grade => grade.exam === examTitle);
if (grade) {
examsWithUsage.push(toNumWithComma(grade.grade));
examsWithUsage.push(grade.selected ? 1 : 0);
} else {
examsWithUsage.push('');
examsWithUsage.push('');
}
}
return examsWithUsage;
}
function toNumWithComma(num: number): string {
return num.toString().replace('.', ',');
}
const nameWidth = 29.6;
const midWidth = 10;
const examGradeWidth = 6.7;
const examSelectedWidth = 1.8;
function colLtr(col: number): string {
const A = 'A'.charCodeAt(0);
return String.fromCharCode(A + col - 1);
}
async function writeTableAsExcel(conclusionFileName: string, conclusionTable: Table, selectBest: number, maxFailed: number, totalExams: number) {
const COL_FINAL = 2; // column B
const COL_SUB = 3; // column C
const COL_SUCCESS = 4; // column D
const COL_MISSING = 5; // column E
const COL_FAILED = 6; // column F
const COL_AVG = 7; // column G
const COL_MIN = 8; // column H
const COL_MAX = 9; // column I
const COL_GRADING = 10; // column J
await writeExcel(conclusionTable, conclusionFileName, (ws) => {
ws.name = 'Conclusion';
ws.getColumn(1).width = nameWidth;
[COL_FINAL, COL_SUB, COL_SUCCESS, COL_MISSING, COL_FAILED].forEach(col => ws.getColumn(col).width = midWidth);
forEachCellInRow(ws, COL_FINAL, 2, (cell) => cell.font = { bold: true });
forEachCellInRow(ws, COL_FINAL, 2, (cell) => cell.alignment = { horizontal: 'center' });
[COL_AVG, COL_MIN, COL_MAX].forEach(col => ws.getColumn(col).width = examGradeWidth);
[COL_FINAL, COL_AVG, COL_MIN, COL_MAX].forEach(col => forEachCellInRow(ws, col, 2, (cell) => cell.numFmt = '0.0'));
[COL_SUB, COL_SUCCESS, COL_MISSING, COL_FAILED].forEach(col => forEachCellInRow(ws, col, 2, (cell) => cell.numFmt = '0'));
headerRow(ws, 1);
let sqRef: string[] = [];
for (let col = COL_GRADING; col < conclusionTable.columnsCount - 2; col += 2) {
ws.getColumn(col).width = examGradeWidth;
forEachCellInRow(ws, col, 2, (cell) => {
cell.numFmt = '0.0';
});
const colStr = toCol(col);
sqRef.push(`${colStr}2:${colStr}${conclusionTable.rowsCount}`);
ws.getColumn(col + 1).width = examSelectedWidth;
forEachCellInRow(ws, col + 1, 2, (cell) => {
cell.fill = bgLightGray
cell.numFmt = '0';
});
}
forEachCellInRow(ws, conclusionTable.columnsCount - 1, 2, (cell) => {
const row = cell.row;
const sumRef: string[] = [];
const countRef: string[] = [];
for (let col = COL_GRADING; col < conclusionTable.columnsCount - 2; col += 2) {
sumRef.push(`${toCol(col)}${row}*${toCol(col + 1)}${row}`);
countRef.push(`${toCol(col + 1)}${row}`);
}
const sumUsed = sumRef.join('+');
const sumCount = countRef.join("+");
cell.value = {
formula: `((${sumUsed})+(${selectBest}-(${sumCount}))*5)/${selectBest}`,
result: asNumber(conclusionTable.getText(2, parseInt(row)))
} as CellFormulaValue;
cell.numFmt = '0.00';
cell.fill = bgLightYellow;
});
ws.addConditionalFormatting({ // selected exams
ref: sqRef.join(" "),
rules: [
{
type: 'expression',
formulae: [`${colLtr(COL_GRADING + 1)}2`],
style: { fill: bgLightGreen },
} as ExpressionRuleType
]
});
ws.addConditionalFormatting({ // graded with 5,0
ref: sqRef.join(" "),
rules: [
{
type: 'cellIs',
operator: 'greaterThan',
formulae: [4],
style: { font: { color: { argb: 'FF9C0000' }, bold: true } },
priority: 2
} as CellIsRuleType
]
});
ws.addConditionalFormatting({ // final grade, min, max grade
ref: `${colLtr(COL_FINAL)}2:${colLtr(COL_FINAL)}${conclusionTable.rowsCount} ${colLtr(COL_MIN)}2:${colLtr(COL_MIN)}${conclusionTable.rowsCount} ${colLtr(COL_MAX)}2:${colLtr(COL_MAX)}${conclusionTable.rowsCount}`,
rules: [
{
type: 'cellIs',
operator: 'equal',
formulae: ['"-"'],
style: { fill: bgVeryLightGray },
// priority: 2
} as CellIsRuleType,
{
type: 'cellIs',
operator: 'greaterThan',
formulae: [4],
style: { fill: bgLightRed },
// priority: 1
} as CellIsRuleType,
]
});
if (maxFailed > 0) {
ws.addConditionalFormatting({ // failed
ref: `${colLtr(COL_FAILED)}2:${colLtr(COL_FAILED)}${conclusionTable.rowsCount}`,
rules: [
{
type: 'cellIs',
operator: 'greaterThan',
formulae: [maxFailed],
style: { fill: bgLightRed },
} as CellIsRuleType
]
});
ws.addConditionalFormatting({ // almost failed
ref: `${colLtr(COL_FAILED)}2:${colLtr(COL_FAILED)}${conclusionTable.rowsCount}`,
rules: [
{
type: 'cellIs',
operator: 'equal',
formulae: [maxFailed],
style: { fill: bgLightYellow },
} as CellIsRuleType
]
});
if (totalExams) {
const minSuccess = (totalExams - maxFailed);
ws.addConditionalFormatting({ // failed
ref: `${colLtr(COL_SUCCESS)}2:${colLtr(COL_SUCCESS)}${conclusionTable.rowsCount}`,
rules: [
{
type: 'cellIs',
operator: 'greaterThan',
formulae: [minSuccess - 1],
style: { fill: bgLightGreen },
} as CellIsRuleType
]
});
}
}
ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 1, topLeftCell: 'B2', activeCell: 'B2' }];
})
}
export async function readManualConclusion(options: OptionValues) {
if (!options.manualConclusionFile) {
verb(`No manual conclusion file specified, skipped.`)
return;
}
const conclusionFile = generateFileName(options.manualConclusionFile, { stdInFolder: options.stdInFolder, resultsDir: options.resultsDir });
if (! await fileExists(conclusionFile, 'conclusionFile')) {
verb(`No manual conclusion file "${conclusionFile}" found, skipped.`);
return undefined;
}
log(`Reading manual conclusion from ${conclusionFile}`)
const conclusionFileContent = await fs.promises.readFile(conclusionFile, "utf-8"); // TODO: PATH
const conclusionSchema = parse(conclusionFileContent) as unknown as ManualConclusion;
return conclusionSchema;
}
export function checkManualConclusion(manualConclusion: ManualConclusion | undefined) {
if (!manualConclusion) {
return;
}
manualConclusion.conclusions.forEach(conclusion => {
if (!conclusion.userName) {
throw new Error(`Error in manual conclusion: No userName found in conclusion.`);
}
if (conclusion.totalGrading > 5 || conclusion.totalGrading < 0) {
throw new Error(`Error in manual conclusion: totalGrading > 5 (or less 0) for ${conclusion.userName}.`);
}
if (conclusion.totalGrading >= 1 && !conclusion.gradingReason) {
throw new Error(`Error in manual conclusion: totalGrading defined but gradingReason missing for ${conclusion.userName}.`)
}
if (!conclusion.totalGrading && conclusion.gradingReason) {
throw new Error(`Error in manual conclusion: totalGrading not defined but gradingReason found for ${conclusion.userName}.`)
}
});
};