From d7898d56787e0da5d1e4f0f9648d9030c43dfbab Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 1 Oct 2026 23:36:12 +0200 Subject: [PATCH] =?UTF-8?q?Punkteeingabe:=20Korrekturmodus=20w=C3=A4hlbar,?= =?UTF-8?q?=20Bonusaufgaben=20nicht=20mitnummeriert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Enter springt: Schüler für Schüler / Aufgabe für Aufgabe" im Dialog wählbar und gemerkt; Umschalt+Enter zurück, Pfeiltasten springen direkt und öffnen das Eingabefeld - Tasten per Tunnel-Handler abgefangen, bevor DataGrid/TextBox sie auswerten - Abwesende Schüler und Aufgaben der anderen Fassung werden übersprungen - Zellen lesen ihre Zeile aus dem DataContext (recycelte Zeilen schreiben nicht mehr falsch) - Bonusaufgaben zählen bei der angezeigten Nummerierung nicht mit Co-Authored-By: Claude Opus 5.5 --- .../Services/ExamGradingSettingsService.cs | 44 +++ LehrerApp.Core/Services/ExamScoringService.cs | 30 ++ .../ExamGradingDialogViewModelTests.cs | 71 +++++ LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Groups/ExamEvaluationViewModels.cs | 10 +- .../Groups/ExamGradingViewModels.cs | 74 ++++- .../ViewModels/Groups/ExamViewModels.cs | 5 + .../Views/Exams/ExamsOverviewView.axaml.cs | 3 +- .../Views/Groups/ExamDialog.axaml | 2 +- .../Views/Groups/ExamGradingDialog.axaml | 4 + .../Views/Groups/ExamGradingDialog.axaml.cs | 284 +++++++++--------- .../Views/Groups/GroupDetailView.axaml.cs | 3 +- LehrerApp.Tests/ExamScoringServiceTests.cs | 21 ++ TODO.md | 12 + 14 files changed, 419 insertions(+), 145 deletions(-) create mode 100644 LehrerApp.Core/Services/ExamGradingSettingsService.cs diff --git a/LehrerApp.Core/Services/ExamGradingSettingsService.cs b/LehrerApp.Core/Services/ExamGradingSettingsService.cs new file mode 100644 index 0000000..b2e1361 --- /dev/null +++ b/LehrerApp.Core/Services/ExamGradingSettingsService.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +/// Reihenfolge, in der Enter bei der Punkteeingabe zum nächsten Feld springt. +public enum GradingEntryOrder +{ + /// Eine Arbeit komplett, dann die nächste (Enter läuft die Zeile entlang). + ByStudent, + /// Eine Aufgabe für alle, dann die nächste (Enter läuft die Spalte hinunter). + ByTask, +} + +public sealed class ExamGradingSettings +{ + public GradingEntryOrder EntryOrder { get; set; } = GradingEntryOrder.ByStudent; +} + +/// Merkt sich den zuletzt gewählten Korrekturmodus der Punkteeingabe lokal. +public sealed class ExamGradingSettingsService +{ + private readonly string _configPath; + + public ExamGradingSettingsService(string appDataPath) => + _configPath = Path.Combine(appDataPath, "examgradingsettings.json"); + + public ExamGradingSettings Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new ExamGradingSettings(); + } + catch { /* beschädigte Konfiguration -> Standard */ } + return new ExamGradingSettings(); + } + + public void Save(ExamGradingSettings settings) + { + try { File.WriteAllText(_configPath, JsonSerializer.Serialize(settings)); } + catch { /* Einstellung ist Komfort — Fehler beim Schreiben nicht an die UI durchreichen */ } + } +} diff --git a/LehrerApp.Core/Services/ExamScoringService.cs b/LehrerApp.Core/Services/ExamScoringService.cs index 7951db7..725a237 100644 --- a/LehrerApp.Core/Services/ExamScoringService.cs +++ b/LehrerApp.Core/Services/ExamScoringService.cs @@ -10,6 +10,36 @@ public static class ExamScoringService public static double Maximum(IEnumerable tasks, ExamVariant variant) => tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints); + /// + /// 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", …). bleibt davon + /// unberührt die reine Positions-/Sortiernummer. + /// + public static List TaskNumbers(IEnumerable 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(); + } + + /// Beschriftung je Aufgabe ("Aufgabe 3", "3. Titel", "Bonus", "Bonus: Titel"). + public static List TaskLabels(IReadOnlyList 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 tasks, IReadOnlyList points, ExamVariant variant) => tasks.Select((task, index) => AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum(); diff --git a/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs index a36039c..0d1cac2 100644 --- a/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/ExamGradingDialogViewModelTests.cs @@ -62,4 +62,75 @@ public sealed class ExamGradingDialogViewModelTests Assert.True(vm.Rows[0].Absent); } + + private static ExamGradingDialogViewModel CreateGrid(int studentCount, params ExamTask[] tasks) + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var students = Enumerable.Range(0, studentCount) + .Select(i => new Student { FirstName = "S", LastName = $"N{i}" }).ToList(); + var memberships = new FakeMemberships( + students.Select(s => new GroupMembership { GroupId = group.Id, StudentId = s.Id }).ToList()); + var exam = new Exam + { + GroupId = group.Id, Title = "Klausur 1", Tasks = [.. tasks], + GradingKey = GradingService.DefaultKey1To6(), + }; + return new ExamGradingDialogViewModel(new FakeResults(), new FakeStudents(students), memberships, + new GradingService(), exam, group.Id); + } + + private static ExamTask[] ThreeTasks() => + [ + new() { Nr = 1, MaxPoints = 5 }, + new() { Nr = 2, MaxPoints = 5 }, + new() { Nr = 3, MaxPoints = 5 }, + ]; + + [Fact] + public void Enter_SchülerFürSchüler_LäuftDieZeileEntlangUndDannInDieNächste() + { + var vm = CreateGrid(2, ThreeTasks()); + + Assert.Equal((0, 1), vm.NextCell(0, 0, GradingMove.Next)); + Assert.Equal((1, 0), vm.NextCell(0, 2, GradingMove.Next)); + Assert.Null(vm.NextCell(1, 2, GradingMove.Next)); + Assert.Equal((0, 2), vm.NextCell(1, 0, GradingMove.Previous)); + } + + [Fact] + public void Enter_AufgabeFürAufgabe_LäuftDieSpalteHinunterUndDannZurNächstenAufgabe() + { + var vm = CreateGrid(2, ThreeTasks()); + vm.EntryOrderName = vm.EntryOrderOptions[1]; + + Assert.Equal((1, 0), vm.NextCell(0, 0, GradingMove.Next)); + Assert.Equal((0, 1), vm.NextCell(1, 0, GradingMove.Next)); + Assert.Equal((1, 0), vm.NextCell(0, 1, GradingMove.Previous)); + } + + [Fact] + public void Navigation_ÜberspringtAbwesendeUndAndereFassung() + { + var vm = CreateGrid(3, + new ExamTask { Nr = 1, MaxPoints = 5 }, + new ExamTask { Nr = 2, MaxPoints = 5, Variant = ExamVariant.B }, + new ExamTask { Nr = 3, MaxPoints = 5 }); + vm.Rows[1].Absent = true; + + Assert.Equal((0, 2), vm.NextCell(0, 0, GradingMove.Next)); + Assert.Equal((0, 2), vm.NextCell(0, 0, GradingMove.Right)); + Assert.Equal((2, 0), vm.NextCell(0, 0, GradingMove.Down)); + Assert.Null(vm.NextCell(0, 0, GradingMove.Up)); + } + + [Fact] + public void Spaltenüberschriften_ZählenBonusaufgabenNichtMit() + { + var vm = CreateGrid(1, + new ExamTask { Nr = 1, MaxPoints = 5 }, + new ExamTask { Nr = 2, MaxPoints = 2, IsBonus = true }, + new ExamTask { Nr = 3, MaxPoints = 5, Title = "Redox" }); + + Assert.Equal(["Aufgabe 1", "★ Bonus", "2. Redox"], vm.TaskHeaders); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 2e1cd74..c8d026f 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -208,6 +208,7 @@ public static class AppBootstrapper services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new PatternScoreSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData)); + services.AddSingleton(_ => new ExamGradingSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData)); services.AddSingleton(); diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs index 5f0f7d8..50d5181 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamEvaluationViewModels.cs @@ -226,6 +226,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject { TaskAnalysis.Clear(); var tasks = _exam.Tasks.OrderBy(t => t.Nr).ToList(); + var labels = ExamScoringService.TaskLabels(tasks); for (var i = 0; i < tasks.Count; i++) { var task = tasks[i]; @@ -234,9 +235,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0 ? 0 : achieved.Average() / task.MaxPoints * 100.0; - var suffix = (task.Variant is null ? "" : $" ({task.Variant})") - + (task.IsBonus ? " · Bonus" : ""); - TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, (task.Title ?? "") + suffix, avgPercent)); + var suffix = task.Variant is null ? "" : $" ({task.Variant})"; + TaskAnalysis.Add(new TaskAnalysisItem(labels[i] + suffix, avgPercent)); } } @@ -316,9 +316,9 @@ public class TaskAnalysisItem public bool IsWeak { get; } public double BarWidth { get; } - public TaskAnalysisItem(int nr, string? title, double avgPercent) + public TaskAnalysisItem(string label, double avgPercent) { - Label = string.IsNullOrWhiteSpace(title) ? $"Aufgabe {nr}" : $"{nr}. {title}"; + Label = label; AvgPercent = avgPercent; AvgPercentDisplay = $"{avgPercent.ToString("0.#", CultureInfo.InvariantCulture)} %"; IsWeak = avgPercent < 50.0; diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs index f452d80..ecc34e8 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamGradingViewModels.cs @@ -10,11 +10,16 @@ namespace LehrerApp.Desktop.ViewModels.Groups; // ── Punkteeingabe & Korrektur (1.4) ────────────────────────────────────────── +/// Tastatur-Bewegung im Punkteraster: Next/Previous folgen dem gewählten Korrekturmodus +/// (Enter / Umschalt+Enter), die übrigen bewegen sich räumlich (Pfeiltasten). +public enum GradingMove { Next, Previous, Up, Down, Left, Right } + public partial class ExamGradingDialogViewModel : ObservableObject { private readonly IExamResultRepository _results; private readonly GradingService _grading; private readonly Exam _exam; + private readonly ExamGradingSettingsService? _settings; public string ExamTitle => _exam.Title; public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy"); @@ -22,13 +27,28 @@ public partial class ExamGradingDialogViewModel : ObservableObject public List Tasks { get; } public bool HasVariants => Tasks.Any(t => t.Variant is not null); + /// Spaltenüberschriften — Bonusaufgaben zählen bei der Nummerierung nicht mit. + public List TaskHeaders { get; } + public ObservableCollection Rows { get; } = []; + public string[] EntryOrderOptions => ["Schüler für Schüler", "Aufgabe für Aufgabe"]; + [ObservableProperty] private string _entryOrderName; + public GradingEntryOrder EntryOrder => + EntryOrderName == EntryOrderOptions[1] ? GradingEntryOrder.ByTask : GradingEntryOrder.ByStudent; + public ExamGradingDialogViewModel(IExamResultRepository results, IStudentRepository students, - IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId) + IGroupMembershipRepository memberships, GradingService grading, Exam exam, Guid groupId, + ExamGradingSettingsService? settings = null) { - _results = results; _grading = grading; _exam = exam; + _results = results; _grading = grading; _exam = exam; _settings = settings; Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); + TaskHeaders = ExamScoringService.TaskLabels(Tasks) + .Select((label, i) => (Tasks[i].IsBonus ? "★ " : "") + label + + (Tasks[i].Variant is null ? "" : $" ({Tasks[i].Variant})")) + .ToList(); + _entryOrderName = EntryOrderOptions[ + settings?.Load().EntryOrder == GradingEntryOrder.ByTask ? 1 : 0]; var enrolled = students.GetByGroup(groupId); var membershipList = memberships.GetByGroup(groupId); @@ -53,6 +73,56 @@ public partial class ExamGradingDialogViewModel : ObservableObject private void SaveRow(ExamResultRow row) => _results.Save(row.ToModel(_exam.Id)); + partial void OnEntryOrderNameChanged(string value) => + _settings?.Save(new ExamGradingSettings { EntryOrder = EntryOrder }); + + /// Nur Felder, in die tatsächlich etwas eingetragen werden kann: abwesende Schüler und + /// Aufgaben der jeweils anderen Fassung werden beim Weiterspringen übersprungen. + public bool IsEditable(int rowIdx, int taskIdx) => + rowIdx >= 0 && rowIdx < Rows.Count && taskIdx >= 0 && taskIdx < Tasks.Count + && !Rows[rowIdx].Absent && Rows[rowIdx].Cells[taskIdx].IsApplicable; + + /// Erstes Feld in Korrekturreihenfolge — dort steht der Cursor beim Öffnen des Dialogs. + public (int Row, int Task)? FirstCell() => + IsEditable(0, 0) ? (0, 0) : NextCell(0, 0, GradingMove.Next); + + /// Zielfeld für eine Tastatur-Bewegung oder null am Rand. Enter läuft je nach Modus + /// zeilen- oder spaltenweise und am Ende einer Zeile/Spalte in die nächste weiter; + /// Pfeiltasten bewegen sich nur innerhalb der Zeile bzw. Spalte und halten am Rand an. + public (int Row, int Task)? NextCell(int rowIdx, int taskIdx, GradingMove move) + { + if (move is GradingMove.Next or GradingMove.Previous) + { + var step = move == GradingMove.Next ? 1 : -1; + var byTask = EntryOrder == GradingEntryOrder.ByTask; + var total = Rows.Count * Tasks.Count; + var pos = byTask ? taskIdx * Rows.Count + rowIdx : rowIdx * Tasks.Count + taskIdx; + for (pos += step; pos >= 0 && pos < total; pos += step) + { + var (r, t) = byTask + ? (pos % Rows.Count, pos / Rows.Count) + : (pos / Tasks.Count, pos % Tasks.Count); + if (IsEditable(r, t)) return (r, t); + } + return null; + } + + var (dRow, dTask) = move switch + { + GradingMove.Up => (-1, 0), + GradingMove.Down => (1, 0), + GradingMove.Left => (0, -1), + _ => (0, 1), + }; + for (int r = rowIdx + dRow, t = taskIdx + dTask; + r >= 0 && r < Rows.Count && t >= 0 && t < Tasks.Count; + r += dRow, t += dTask) + { + if (IsEditable(r, t)) return (r, t); + } + return null; + } + /// Rest als abwesend markieren (Nutzerwunsch): Schüler, die nie nachschreiben, blockieren /// sonst dauerhaft den live abgeleiteten Korrekturstatus (ExamPriorityService), weil niemand /// eine leere Zeile anfasst, für die es nichts einzutragen gibt. Rührt bewusst nur unberührte diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index 1896730..e85e7a2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -229,10 +229,13 @@ public partial class ExamDialogViewModel : ObservableObject private void RenumberTasks() { for (var i = 0; i < Tasks.Count; i++) Tasks[i].Nr = i + 1; + var numbers = ExamScoringService.TaskNumbers(Tasks.Select(t => t.IsBonus)); + for (var i = 0; i < Tasks.Count; i++) Tasks[i].DisplayNr = Tasks[i].IsBonus ? "★" : numbers[i]; } private void RecomputeTotals() { + RenumberTasks(); TotalPoints = Tasks.Where(t => !t.IsBonus && t.VariantName != "B").Sum(t => t.MaxPoints); OnPropertyChanged(nameof(TotalPointsWarning)); OnPropertyChanged(nameof(TotalPointsDisplay)); @@ -422,6 +425,8 @@ public partial class GradingKeyEntryEditItem : ObservableObject public partial class ExamTaskEditItem : ObservableObject { [ObservableProperty] private int _nr; + /// Angezeigte Nummer — Bonusaufgaben zählen nicht mit (siehe ExamScoringService.TaskNumbers). + [ObservableProperty] private string _displayNr = ""; [ObservableProperty] private string _title = ""; [ObservableProperty] private double _maxPoints; [ObservableProperty] private double _weight = 1.0; diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs index d4af1cd..d26f7d9 100644 --- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs +++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml.cs @@ -38,7 +38,8 @@ public partial class ExamsOverviewView : UserControl App.Services.GetRequiredService(), App.Services.GetRequiredService(), App.Services.GetRequiredService(), - exam, group.Id); + exam, group.Id, + App.Services.GetRequiredService()); var dialog = new ExamGradingDialog { DataContext = dialogVm }; var owner = TopLevel.GetTopLevel(this) as Window; diff --git a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml index 7d01a17..ec23978 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamDialog.axaml @@ -105,7 +105,7 @@ BorderThickness="1" CornerRadius="4" Padding="8" Margin="0,0,0,6"> - + diff --git a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml index e359db8..84732ab 100644 --- a/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/ExamGradingDialog.axaml @@ -18,6 +18,10 @@