diff --git a/LehrerApp.Desktop.Tests/RandomStudentPickerViewModelTests.cs b/LehrerApp.Desktop.Tests/RandomStudentPickerViewModelTests.cs new file mode 100644 index 0000000..260f575 --- /dev/null +++ b/LehrerApp.Desktop.Tests/RandomStudentPickerViewModelTests.cs @@ -0,0 +1,89 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class RandomStudentPickerViewModelTests +{ + private static (RandomStudentPickerViewModel Vm, List Students, LearningGroup Group) Build(int seed = 17) + { + var group = new LearningGroup { Name = "8a" }; + var students = new List + { + new() { FirstName = "Anna", LastName = "Adler" }, + new() { FirstName = "Ben", LastName = "Bauer" }, + new() { FirstName = "Cem", LastName = "Can" }, + }; + var memberships = students.Select(s => new GroupMembership + { + GroupId = group.Id, + StudentId = s.Id, + }).ToList(); + var vm = new RandomStudentPickerViewModel( + new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships), + new FakeSessions([]), new FakeEntries(), group.Id, new Random(seed)); + return (vm, students, group); + } + + [Fact] + public void Vollrunde_ZiehtAlleEinmal_BevorJemandWiederholtWird() + { + var (vm, _, _) = Build(); + var names = new List(); + + for (var i = 0; i < 3; i++) + { + vm.DrawCommand.Execute(null); + names.Add(vm.SelectedStudentName); + } + + Assert.Equal(3, names.Distinct().Count()); + Assert.Contains("Alle waren dran", vm.StatusText); + } + + [Fact] + public void Reset_StartetZiehungOhneAngezeigtenNamenNeu() + { + var (vm, _, _) = Build(); + vm.DrawCommand.Execute(null); + + vm.ResetCommand.Execute(null); + + Assert.False(vm.HasDrawnStudent); + Assert.Equal("Bereit?", vm.SelectedStudentName); + } + + [Fact] + public void NurHeuteAktiveMitglieder_StehenZurAuswahl() + { + var group = new LearningGroup { Name = "8a" }; + var active = new Student { FirstName = "Aktiv", LastName = "A" }; + var former = new Student { FirstName = "Ehemalig", LastName = "E" }; + var memberships = new List + { + new() { GroupId = group.Id, StudentId = active.Id }, + new() { GroupId = group.Id, StudentId = former.Id, LeftAt = DateOnly.FromDateTime(DateTime.Today).AddDays(-1) }, + }; + var vm = new RandomStudentPickerViewModel( + new FakeStudents([active, former]), new FakeGroups([group]), new FakeMemberships(memberships), + new FakeSessions([]), new FakeEntries(), group.Id, new Random(1)); + + vm.DrawCommand.Execute(null); + + Assert.Equal(active.FullName, vm.SelectedStudentName); + } + + [Fact] + public void IntelligenteZiehung_VermeidetDirekteWiederholung() + { + var (vm, _, _) = Build(2); + vm.SelectionModeName = RandomStudentPickerViewModel.IntelligentLabel; + vm.DrawCommand.Execute(null); + var first = vm.SelectedStudentName; + + vm.DrawCommand.Execute(null); + + Assert.NotEqual(first, vm.SelectedStudentName); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/RandomStudentPickerViewModel.cs b/LehrerApp.Desktop/ViewModels/Groups/RandomStudentPickerViewModel.cs new file mode 100644 index 0000000..9e7a07e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/RandomStudentPickerViewModel.cs @@ -0,0 +1,200 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +public enum StudentDrawMode { FullRound, Intelligent } + +public sealed record StudentDrawGroup(Guid Id, string Name); + +/// +/// Steuert das eigenstaendige Ziehfenster. Die Ziehhistorie ist bewusst nur fuer die Laufzeit +/// des Fensters gueltig; es entstehen dadurch keine zusaetzlichen personenbezogenen Datensaetze. +/// +public partial class RandomStudentPickerViewModel : ObservableObject +{ + public const string FullRoundLabel = "Alle einmal, dann neu"; + public const string IntelligentLabel = "Intelligent ausgleichen"; + + private readonly IStudentRepository _students; + private readonly IGroupMembershipRepository _memberships; + private readonly IParticipationSessionRepository _sessions; + private readonly IParticipationRepository _participation; + private readonly Random _random; + private readonly List _candidates = []; + private readonly List _roundBag = []; + private readonly Dictionary _drawCounts = []; + private readonly Dictionary _signals = []; + private Guid? _lastStudentId; + + [ObservableProperty] private StudentDrawGroup? _selectedGroup; + [ObservableProperty] private string _selectionModeName = FullRoundLabel; + [ObservableProperty] private bool _considerParticipation = true; + [ObservableProperty] private string _selectedStudentName = "Bereit?"; + [ObservableProperty] private string _statusText = "Wähle in den Optionen eine Lerngruppe."; + [ObservableProperty] private bool _hasDrawnStudent; + + public ObservableCollection Groups { get; } = []; + public string[] SelectionModeOptions { get; } = [FullRoundLabel, IntelligentLabel]; + public bool IsIntelligent => SelectionModeName == IntelligentLabel; + public bool CanDraw => SelectedGroup is not null && _candidates.Count > 0; + + public RandomStudentPickerViewModel( + IStudentRepository students, + IGroupRepository groups, + IGroupMembershipRepository memberships, + IParticipationSessionRepository sessions, + IParticipationRepository participation, + Guid? initialGroupId = null, + Random? random = null) + { + _students = students; + _memberships = memberships; + _sessions = sessions; + _participation = participation; + _random = random ?? Random.Shared; + + foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase)) + Groups.Add(new StudentDrawGroup(group.Id, group.Name)); + + SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault(); + } + + partial void OnSelectedGroupChanged(StudentDrawGroup? value) => ReloadGroup(); + + partial void OnSelectionModeNameChanged(string value) + { + OnPropertyChanged(nameof(IsIntelligent)); + ResetDrawState(); + } + + partial void OnConsiderParticipationChanged(bool value) => ResetDrawState(); + + [RelayCommand(CanExecute = nameof(CanDraw))] + private void Draw() + { + var student = IsIntelligent ? DrawIntelligently() : DrawFromFullRound(); + _drawCounts[student.Id] = _drawCounts.GetValueOrDefault(student.Id) + 1; + _lastStudentId = student.Id; + SelectedStudentName = student.FullName; + HasDrawnStudent = true; + UpdateStatus(); + } + + [RelayCommand] + private void Reset() => ResetDrawState(); + + private void ReloadGroup() + { + _candidates.Clear(); + _signals.Clear(); + + if (SelectedGroup is not null) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var activeIds = _memberships.GetByGroup(SelectedGroup.Id) + .Where(m => GroupMembershipService.IsActiveOn(m, today)) + .Select(m => m.StudentId) + .ToHashSet(); + _candidates.AddRange(_students.GetByGroup(SelectedGroup.Id) + .Where(s => s.IsActive && activeIds.Contains(s.Id)) + .OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase)); + LoadParticipationSignals(SelectedGroup.Id); + } + + ResetDrawState(); + OnPropertyChanged(nameof(CanDraw)); + DrawCommand.NotifyCanExecuteChanged(); + } + + private void LoadParticipationSignals(Guid groupId) + { + var sessionIds = _sessions.GetByGroup(groupId).Select(s => s.Id).ToList(); + var entries = sessionIds.SelectMany(_participation.GetBySession).ToList(); + + foreach (var student in _candidates) + { + var own = entries.Where(e => e.StudentId == student.Id).ToList(); + var activity = own.Sum(e => e.RaisedHandCount + e.CalledOnCount); + var values = own.SelectMany(e => e.Ratings).Select(r => Math.Clamp(r.Value, -2, 2)).ToList(); + _signals[student.Id] = new ParticipationSignal( + activity, + values.Count == 0 ? null : values.Average()); + } + } + + private Student DrawFromFullRound() + { + if (_roundBag.Count == 0) + { + _roundBag.AddRange(_candidates.Select(s => s.Id)); + for (var i = _roundBag.Count - 1; i > 0; i--) + { + var swap = _random.Next(i + 1); + (_roundBag[i], _roundBag[swap]) = (_roundBag[swap], _roundBag[i]); + } + } + + var id = _roundBag[^1]; + _roundBag.RemoveAt(_roundBag.Count - 1); + return _candidates.Single(s => s.Id == id); + } + + private Student DrawIntelligently() + { + var maxActivity = Math.Max(1, _signals.Values.Select(s => s.Activity).DefaultIfEmpty().Max()); + var eligible = _candidates.Count > 1 + ? _candidates.Where(student => student.Id != _lastStudentId) + : _candidates; + var weighted = eligible.Select(student => + { + var weight = 1.0 / (1.0 + 1.5 * _drawCounts.GetValueOrDefault(student.Id)); + if (ConsiderParticipation && _signals.TryGetValue(student.Id, out var signal)) + { + var activityRatio = signal.Activity / (double)maxActivity; + weight *= 1.65 - activityRatio; // wenig Aktive werden wahrscheinlicher gezogen + if (signal.AverageRating is { } rating) + weight *= 1.0 - Math.Clamp(rating, -2, 2) * 0.175; // starke spaeter, schwache frueher + } + return (Student: student, Weight: Math.Max(0.01, weight)); + }).ToList(); + + var point = _random.NextDouble() * weighted.Sum(x => x.Weight); + foreach (var item in weighted) + { + point -= item.Weight; + if (point <= 0) return item.Student; + } + return weighted[^1].Student; + } + + private void ResetDrawState() + { + _roundBag.Clear(); + _drawCounts.Clear(); + _lastStudentId = null; + HasDrawnStudent = false; + SelectedStudentName = "Bereit?"; + UpdateStatus(); + } + + private void UpdateStatus() + { + StatusText = _candidates.Count switch + { + 0 when SelectedGroup is null => "Wähle in den Optionen eine Lerngruppe.", + 0 => "In dieser Lerngruppe sind heute keine aktiven Schüler.", + _ when !HasDrawnStudent => $"{_candidates.Count} Schüler stehen zur Auswahl.", + _ when !IsIntelligent && _roundBag.Count == 0 => "Alle waren dran. Der nächste Zug startet eine neue Runde.", + _ when !IsIntelligent => $"Noch {_roundBag.Count} bis zur nächsten Runde.", + _ => "Nächste Ziehung bereit.", + }; + } + + private sealed record ParticipationSignal(int Activity, double? AverageRating); +} diff --git a/LehrerApp.Desktop/Views/Groups/RandomStudentPickerWindow.axaml b/LehrerApp.Desktop/Views/Groups/RandomStudentPickerWindow.axaml new file mode 100644 index 0000000..a069c30 --- /dev/null +++ b/LehrerApp.Desktop/Views/Groups/RandomStudentPickerWindow.axaml @@ -0,0 +1,77 @@ + + + + + + + + +