Files
LehrerApp/LehrerApp.Core/Services/ExamScoringService.cs
T
adminandClaude Sonnet 5.5 c0f8b98202
CI / build-and-test (push) Canceled after 0s
Nachschreibarbeiten und Arbeitsarten (Test, kontrollierte Hausaufgabe)
Nachschreibarbeiten nutzen die Wiederholungs-Verknüpfung, ersetzen aber nur
fehlende Noten; mehrere Folgearbeiten je erster Arbeit sind möglich.
Exam.Kind trennt Klausuren (schriftlich) von Tests u. Ä. (Sonstige).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:54:31 +02:00

117 lines
5.5 KiB
C#

using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public static class ExamScoringService
{
public static bool AppliesTo(ExamTask task, ExamVariant variant) =>
task.Variant is null || task.Variant == variant;
public static double Maximum(IEnumerable<ExamTask> tasks, ExamVariant variant) =>
tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints);
/// <summary>
/// Anzeigenummern der Aufgaben in der gespeicherten Reihenfolge. Bonusaufgaben werden auf
/// dem Aufgabenblatt üblicherweise nicht nummeriert — würden sie mitgezählt, passten alle
/// Nummern hinter einer Bonusaufgabe nicht mehr zur gedruckten Arbeit. Sie heißen daher
/// "Bonus" (bei mehreren "Bonus 1", "Bonus 2", …). <see cref="ExamTask.Nr"/> bleibt davon
/// unberührt die reine Positions-/Sortiernummer.
/// </summary>
public static List<string> TaskNumbers(IEnumerable<bool> isBonus)
{
var flags = isBonus.ToList();
var multipleBonus = flags.Count(b => b) > 1;
int nr = 0, bonus = 0;
return flags.Select(b => b
? multipleBonus ? $"Bonus {++bonus}" : "Bonus"
: (++nr).ToString())
.ToList();
}
/// <summary>Beschriftung je Aufgabe ("Aufgabe 3", "3. Titel", "Bonus", "Bonus: Titel").</summary>
public static List<string> TaskLabels(IReadOnlyList<ExamTask> orderedTasks) =>
TaskNumbers(orderedTasks.Select(t => t.IsBonus))
.Select((number, i) =>
{
var title = orderedTasks[i].Title;
if (orderedTasks[i].IsBonus)
return string.IsNullOrWhiteSpace(title) ? number : $"{number}: {title}";
return string.IsNullOrWhiteSpace(title) ? $"Aufgabe {number}" : $"{number}. {title}";
})
.ToList();
public static double Achieved(IReadOnlyList<ExamTask> tasks, IReadOnlyList<double> points,
ExamVariant variant) => tasks.Select((task, index) =>
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
/// <summary>
/// Wählt pro Arbeit samt allen verknüpften Folgearbeiten (Wiederholung, Nachschreibarbeit)
/// genau eine Note. Andere Arbeiten bleiben erhalten. Fehlt die erste Arbeit (Schüler war
/// abwesend), zählt die Nachschreibarbeit allein; hat der Schüler beide geschrieben, zählt bei
/// einer Nachschreibarbeit immer die erste — sie ersetzt nur eine fehlende Note.
/// </summary>
public static List<string> EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
GradingSystem system)
{
var chains = new List<List<(Exam Exam, string Grade)>>();
var chainByRoot = new Dictionary<Guid, List<(Exam Exam, string Grade)>>();
foreach (var entry in entries)
{
var root = entry.Exam.RepeatOfExamId ?? entry.Exam.Id;
if (!chainByRoot.TryGetValue(root, out var chain))
chains.Add(chainByRoot[root] = chain = []);
chain.Add(entry);
}
var result = new List<string>();
foreach (var chain in chains)
{
// Die erste Arbeit steht vorn, Folgearbeiten chronologisch dahinter.
var ordered = chain.OrderBy(x => x.Exam.RepeatOfExamId is null ? 0 : 1)
.ThenBy(x => x.Exam.Date).ToList();
var current = ordered[0].Grade;
foreach (var next in ordered.Skip(1))
current = Choose(current, next.Grade,
next.Exam.IsMakeUp ? RepeatGradeRule.Original : next.Exam.RepeatGradeRule, system);
result.Add(current);
}
return result;
}
/// <summary>
/// Wie <see cref="EffectiveGrades"/>, trennt aber nach Art: Klausuren zählen als schriftliche
/// Note, Tests u. Ä. fließen unter "Sonstige" ein. Verknüpfte Arbeiten erben die Art der
/// ersten Arbeit (der Dialog erzwingt das), landen also immer im selben Topf.
/// </summary>
public static (List<string> Written, List<string> Other) EffectiveGradesByKind(
IEnumerable<(Exam Exam, string Grade)> entries, GradingSystem system)
{
var list = entries.ToList();
return (
EffectiveGrades(list.Where(x => x.Exam.Kind.CountsAsWritten()), system),
EffectiveGrades(list.Where(x => !x.Exam.Kind.CountsAsWritten()), system));
}
private static string Choose(string original, string repeat, RepeatGradeRule rule, GradingSystem system)
{
if (rule == RepeatGradeRule.Original) return original;
if (rule == RepeatGradeRule.Repeat) return repeat;
var first = GradeValue(original, system);
var second = GradeValue(repeat, system);
if (first is null || second is null) return original;
return system == GradingSystem.Grades1To6
? (second < first ? repeat : original)
: (second > first ? repeat : original);
}
private static double? GradeValue(string grade, GradingSystem system)
{
var core = grade.Trim();
var tendency = 0.0;
if (core.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; core = core[..^1]; }
else if (core.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; core = core[..^1]; }
return double.TryParse(core.Trim(), System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value + tendency : null;
}
}