diff --git a/LehrerApp.Desktop.Tests/ExamRevealDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/ExamRevealDialogViewModelTests.cs new file mode 100644 index 0000000..b1cf2b3 --- /dev/null +++ b/LehrerApp.Desktop.Tests/ExamRevealDialogViewModelTests.cs @@ -0,0 +1,94 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Deckt 1.7.1 (datenschutzkonforme Einzelansicht) ab. +public sealed class ExamRevealDialogViewModelTests +{ + private static (ExamRevealDialogViewModel Vm, Student Anna, Student Ben) Build() + { + var group = new LearningGroup { Name = "Q1 Chemie" }; + var anna = new Student { FirstName = "Anna", LastName = "Bauer" }; + var ben = new Student { FirstName = "Ben", LastName = "Cordes" }; + var withoutResult = new Student { FirstName = "Cara", LastName = "Diehl" }; + var exam = new Exam + { + GroupId = group.Id, + Title = "Klausur 1", + Tasks = + [ + new ExamTask { Nr = 1, MaxPoints = 10 }, + new ExamTask { Nr = 2, MaxPoints = 10, Variant = ExamVariant.A }, + new ExamTask { Nr = 3, MaxPoints = 20, Variant = ExamVariant.B }, + ], + GradingKey = GradingService.DefaultKey1To6(), + }; + var results = new FakeResults(); + // Bewusst in "falscher" Reihenfolge eingefügt — die Liste sortiert nach Nachname. + results.Add(new ExamResult { ExamId = exam.Id, StudentId = ben.Id, Absent = true }); + results.Add(new ExamResult + { + ExamId = exam.Id, StudentId = anna.Id, Points = [7.5, 9, 0], TotalPoints = 16.5, + Grade = "2", Variant = ExamVariant.A, + }); + + var vm = new ExamRevealDialogViewModel(results, new FakeStudents([ben, withoutResult, anna]), + new GradingService(), exam); + return (vm, anna, ben); + } + + [Fact] + public void Start_ZeigtErstenSchülerZugedeckt_UndNurSchülerMitErgebnis() + { + var (vm, _, _) = Build(); + + Assert.Equal(["Bauer, Anna", "Cordes, Ben"], vm.Students.Select(s => s.ListName)); + Assert.Equal("Anna Bauer", vm.SelectedStudent!.DisplayName); + Assert.False(vm.IsRevealed); + Assert.Equal("1 / 2", vm.PositionLabel); + } + + [Fact] + public void Ergebnis_ZeigtNotePunkteUndNurAufgabenDerGeschriebenenFassung() + { + var (vm, _, _) = Build(); + var anna = vm.SelectedStudent!; + + Assert.Equal("2", anna.GradeDisplay); + Assert.Equal("16,5 von 20 Punkten", anna.PointsDisplay); + Assert.Equal( + [new ExamRevealTaskRow("Aufgabe 1", "7,5", "10"), new ExamRevealTaskRow("Aufgabe 2", "9", "10")], + anna.Tasks); + } + + [Fact] + public void Schülerwechsel_DecktWiederZu() + { + var (vm, _, _) = Build(); + vm.RevealCommand.Execute(null); + Assert.True(vm.IsRevealed); + + vm.NextCommand.Execute(null); + + Assert.False(vm.IsRevealed); + Assert.Equal("Ben Cordes", vm.SelectedStudent!.DisplayName); + Assert.True(vm.SelectedStudent.Absent); + Assert.Empty(vm.SelectedStudent.Tasks); + Assert.False(vm.NextCommand.CanExecute(null)); + Assert.True(vm.PreviousCommand.CanExecute(null)); + } + + [Fact] + public void Zudecken_VerstecktErgebnisWieder() + { + var (vm, _, _) = Build(); + vm.RevealCommand.Execute(null); + + vm.HideCommand.Execute(null); + + Assert.False(vm.IsRevealed); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs index beeccba..9a94e3f 100644 --- a/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Exams/ExamsOverviewViewModel.cs @@ -42,6 +42,7 @@ public partial class ExamsOverviewViewModel : ObservableObject public Func? OnGradeExam { get; set; } public Func? OnEvaluateExam { get; set; } + public Func? OnRevealExam { get; set; } public Action? OnNavigateToGroup { get; set; } public Action? OnNavigateToGroups { get; set; } public bool HasNoExams => Rows.Count == 0; @@ -169,6 +170,13 @@ public partial class ExamsOverviewViewModel : ObservableObject Load(); } + [RelayCommand] + private async Task RevealSelected() + { + if (SelectedRow is null || OnRevealExam is null) return; + await OnRevealExam(SelectedRow.Exam); + } + [RelayCommand] private void GoToGroup() { diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamRevealViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamRevealViewModels.cs new file mode 100644 index 0000000..9905592 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamRevealViewModels.cs @@ -0,0 +1,125 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Globalization; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Datenschutzkonforme Einzelansicht (1.7) ────────────────────────────────── + +/// Zeigt die Leistung genau eines Schülers — zunächst nur den Namen, das Ergebnis erst nach +/// "Aufdecken". So kann ein einzelner Schüler nach vorne geholt werden, ohne dass er (oder die +/// Klasse über den Beamer) Ergebnisse anderer sieht. Rein lesend. +public partial class ExamRevealDialogViewModel : ObservableObject +{ + private readonly Exam _exam; + + public string ExamTitle => _exam.Title; + public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy"); + + public List Students { get; } = []; + public bool HasStudents => Students.Count > 0; + + [ObservableProperty] private ExamRevealStudent? _selectedStudent; + [ObservableProperty] private bool _isRevealed; + + public string PositionLabel => SelectedStudent is null + ? "" + : $"{Students.IndexOf(SelectedStudent) + 1} / {Students.Count}"; + + public ExamRevealDialogViewModel(IExamResultRepository results, IStudentRepository students, + GradingService grading, Exam exam) + { + _exam = exam; + var tasks = exam.Tasks.OrderBy(t => t.Nr).ToList(); + var labels = ExamScoringService.TaskLabels(tasks); + var enrolled = students.GetByGroup(exam.GroupId).ToDictionary(s => s.Id); + + // Grundlage sind die eingetragenen Ergebnisse: wer keins hat (anderes Niveau, nie + // angefasste Zeile), hat hier auch nichts zu sehen. Ausgetretene Schüler mit Ergebnis + // bleiben über GetById auffindbar. + Students = results.GetByExam(exam.Id) + .Select(r => (Result: r, Student: enrolled.GetValueOrDefault(r.StudentId) ?? students.GetById(r.StudentId))) + .Where(x => x.Student is not null) + .OrderBy(x => x.Student!.LastName).ThenBy(x => x.Student!.FirstName) + .Select(x => new ExamRevealStudent(x.Student!, x.Result, tasks, labels, exam.GradingKey, grading)) + .ToList(); + _selectedStudent = Students.FirstOrDefault(); + } + + /// Beim Wechsel wird immer wieder zugedeckt — sonst stünde beim Durchblättern sofort das + /// Ergebnis des nächsten Schülers offen auf dem Bildschirm. + partial void OnSelectedStudentChanged(ExamRevealStudent? value) + { + IsRevealed = false; + OnPropertyChanged(nameof(PositionLabel)); + PreviousCommand.NotifyCanExecuteChanged(); + NextCommand.NotifyCanExecuteChanged(); + RevealCommand.NotifyCanExecuteChanged(); + } + + private int SelectedIndex => SelectedStudent is null ? -1 : Students.IndexOf(SelectedStudent); + private bool CanGoPrevious() => SelectedIndex > 0; + private bool CanGoNext() => SelectedIndex >= 0 && SelectedIndex < Students.Count - 1; + + [RelayCommand(CanExecute = nameof(CanGoPrevious))] + private void Previous() => SelectedStudent = Students[SelectedIndex - 1]; + + [RelayCommand(CanExecute = nameof(CanGoNext))] + private void Next() => SelectedStudent = Students[SelectedIndex + 1]; + + [RelayCommand(CanExecute = nameof(CanReveal))] + private void Reveal() => IsRevealed = true; + private bool CanReveal() => SelectedStudent is not null; + + [RelayCommand] + private void Hide() => IsRevealed = false; +} + +/// Ergebnis eines Schülers, fertig aufbereitet für die Einzelansicht. +public class ExamRevealStudent +{ + private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); + + /// "Nachname, Vorname" — für die sortierte Auswahlliste. + public string ListName { get; } + /// "Vorname Nachname" — für die große Anzeige. + public string DisplayName { get; } + public bool Absent { get; } + public bool HasGrade => !Absent; + public string GradeDisplay { get; } + public string PointsDisplay { get; } + public List Tasks { get; } = []; + + public ExamRevealStudent(Student student, ExamResult result, IReadOnlyList orderedTasks, + IReadOnlyList taskLabels, List gradingKey, GradingService grading) + { + ListName = student.FullName; + DisplayName = $"{student.FirstName} {student.LastName}".Trim(); + Absent = result.Absent; + if (Absent) + { + GradeDisplay = "abwesend"; + PointsDisplay = ""; + return; + } + + var maximum = ExamScoringService.Maximum(orderedTasks, result.Variant); + GradeDisplay = result.Grade ?? grading.CalculateGrade(result.TotalPoints, maximum, gradingKey); + PointsDisplay = $"{Format(result.TotalPoints)} von {Format(maximum)} Punkten"; + + // Nur die Aufgaben der geschriebenen Fassung — die der anderen gab es für den Schüler nicht. + for (var i = 0; i < orderedTasks.Count; i++) + { + if (!ExamScoringService.AppliesTo(orderedTasks[i], result.Variant)) continue; + var points = i < result.Points.Count ? result.Points[i] : 0; + Tasks.Add(new ExamRevealTaskRow(taskLabels[i], Format(points), Format(orderedTasks[i].MaxPoints))); + } + } + + private static string Format(double value) => value.ToString("0.##", German); +} + +public record ExamRevealTaskRow(string Label, string Points, string MaxPoints); diff --git a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs index e94243d..fa14c0c 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/GroupViewModels.cs @@ -234,6 +234,7 @@ public partial class GroupDetailViewModel : ObservableObject public Func>? OnConfirmDeleteExam { get; set; } public Func? OnGradeExam { get; set; } public Func? OnEvaluateExam { get; set; } + public Func? OnRevealExam { get; set; } public Func>? OnConfirmReactivate { get; set; } public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students, @@ -411,6 +412,15 @@ public partial class GroupDetailViewModel : ObservableObject await OnEvaluateExam(exam); } + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] + private async Task RevealExam() + { + if (SelectedExam is null || OnRevealExam is null) return; + var exam = _exams.GetById(SelectedExam.Id); + if (exam is null) return; + await OnRevealExam(exam); + } + [RelayCommand(CanExecute = nameof(CanEditSelectedExam))] private async Task DuplicateExam() { @@ -485,6 +495,7 @@ public partial class GroupDetailViewModel : ObservableObject EditExamCommand.NotifyCanExecuteChanged(); GradeExamCommand.NotifyCanExecuteChanged(); EvaluateExamCommand.NotifyCanExecuteChanged(); + RevealExamCommand.NotifyCanExecuteChanged(); DuplicateExamCommand.NotifyCanExecuteChanged(); DeleteExamCommand.NotifyCanExecuteChanged(); AdvanceExamStatusCommand.NotifyCanExecuteChanged(); @@ -514,6 +525,7 @@ public partial class GroupDetailViewModel : ObservableObject EditExamCommand.NotifyCanExecuteChanged(); GradeExamCommand.NotifyCanExecuteChanged(); EvaluateExamCommand.NotifyCanExecuteChanged(); + RevealExamCommand.NotifyCanExecuteChanged(); DuplicateExamCommand.NotifyCanExecuteChanged(); DeleteExamCommand.NotifyCanExecuteChanged(); AdvanceExamStatusCommand.NotifyCanExecuteChanged(); diff --git a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml index d2ee1a3..3f2a71d 100644 --- a/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml +++ b/LehrerApp.Desktop/Views/Exams/ExamsOverviewView.axaml @@ -174,6 +174,9 @@ IsVisible="{Binding ShowCorrectionProgress}"/>