Add intelligent random student picker
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-29 14:02:48 +02:00
1 parent 770a1833c8
commit c79d96c5ec
7 files changed
+426

No files matched your search

@@ -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<Student> Students, LearningGroup Group) Build(int seed = 17)
{
var group = new LearningGroup { Name = "8a" };
var students = new List<Student>
{
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<string>();
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<GroupMembership>
{
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);
}
}
@@ -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);
/// <summary>
/// Steuert das eigenstaendige Ziehfenster. Die Ziehhistorie ist bewusst nur fuer die Laufzeit
/// des Fensters gueltig; es entstehen dadurch keine zusaetzlichen personenbezogenen Datensaetze.
/// </summary>
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<Student> _candidates = [];
private readonly List<Guid> _roundBag = [];
private readonly Dictionary<Guid, int> _drawCounts = [];
private readonly Dictionary<Guid, ParticipationSignal> _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<StudentDrawGroup> 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);
}
@@ -0,0 +1,77 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.RandomStudentPickerWindow"
x:DataType="vm:RandomStudentPickerViewModel"
Title="Schüler ziehen"
Width="430" MinHeight="390"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,18">
<StackPanel Spacing="3">
<TextBlock Text="Schüler ziehen" Classes="dialogtitle"/>
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="↺" Command="{Binding ResetCommand}"
ToolTip.Tip="Ziehung zurücksetzen" FontSize="18"
Width="38" Height="38"/>
</Grid>
<Border Grid.Row="1" x:Name="RevealCard"
MinHeight="190" Padding="24"
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
BorderBrush="{DynamicResource AppFilterActiveBorderBrush}"
BorderThickness="1" CornerRadius="18">
<Border.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.18"/>
</Transitions>
</Border.Transitions>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="12">
<TextBlock Text="◆" HorizontalAlignment="Center" FontSize="20"
Foreground="{DynamicResource AppAccentOnSoftBrush}"/>
<TextBlock Text="{Binding SelectedStudentName}"
HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="34" FontWeight="SemiBold"
Foreground="{DynamicResource AppFilterActiveForegroundBrush}"/>
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center"
TextAlignment="Center" TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
</StackPanel>
</Border>
<Button Grid.Row="2" Content="🎲 Ziehen" x:Name="DrawButton"
IsEnabled="{Binding CanDraw}" Click="OnDraw"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
FontSize="18" FontWeight="SemiBold" Height="54" Margin="0,16,0,12"/>
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
<StackPanel Spacing="10" Margin="0,10,0,0">
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:StudentDrawGroup">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Auswahlart" FontSize="12" Opacity="0.7" Margin="0,4,0,0"/>
<ComboBox ItemsSource="{Binding SelectionModeOptions}"
SelectedItem="{Binding SelectionModeName}"
HorizontalAlignment="Stretch"/>
<CheckBox Content="Mündliche Mitarbeit berücksichtigen"
IsChecked="{Binding ConsiderParticipation}"
IsEnabled="{Binding IsIntelligent}" Margin="0,4,0,0"/>
<TextBlock Text="Wenig aktive oder bislang schwächere Schüler werden wahrscheinlicher gezogen; direkte Wiederholungen werden vermieden."
IsVisible="{Binding IsIntelligent}" TextWrapping="Wrap"
FontSize="11" Opacity="0.55"/>
</StackPanel>
</Expander>
</Grid>
</Window>
@@ -0,0 +1,26 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class RandomStudentPickerWindow : Window
{
private bool _isAnimating;
public RandomStudentPickerWindow() => InitializeComponent();
private async void OnDraw(object? sender, RoutedEventArgs e)
{
if (_isAnimating || DataContext is not RandomStudentPickerViewModel vm ||
!vm.DrawCommand.CanExecute(null)) return;
_isAnimating = true;
var card = this.FindControl<Border>("RevealCard");
if (card is not null) card.Opacity = 0.15;
await Task.Delay(150);
vm.DrawCommand.Execute(null);
if (card is not null) card.Opacity = 1;
_isAnimating = false;
}
}
+3
View File
@@ -46,6 +46,9 @@
IsEnabled="{Binding CanPersonalizeWorksheet}"
ToolTip.Tip="Nur verfügbar, während eine einzelne Lerngruppe geöffnet ist"/>
</MenuItem>
<MenuItem Header="Unterricht">
<MenuItem Header="Schüler ziehen…" Click="OnOpenRandomStudentPicker"/>
</MenuItem>
</Menu>
<DrawerPage x:Name="RootDrawer"
DrawerLength="220"
@@ -22,6 +22,7 @@ public partial class MainWindow : Window
private SyncEngine? _syncEngine;
private bool _finalSyncStarted;
private bool _closeAfterFinalSync;
private RandomStudentPickerWindow? _randomStudentPickerWindow;
public MainWindow()
{
@@ -71,6 +72,27 @@ public partial class MainWindow : Window
await new PersonalizeWorksheetDialog { DataContext = dialogVm }.ShowDialog(this);
}
private void OnOpenRandomStudentPicker(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_randomStudentPickerWindow is not null)
{
_randomStudentPickerWindow.Activate();
return;
}
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
var vm = new RandomStudentPickerViewModel(
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
currentGroupId);
_randomStudentPickerWindow = new RandomStudentPickerWindow { DataContext = vm };
_randomStudentPickerWindow.Closed += (_, _) => _randomStudentPickerWindow = null;
_randomStudentPickerWindow.Show();
}
private void OnWindowKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainWindowViewModel vm) return;
+9
View File
@@ -3682,6 +3682,15 @@ Hinweis in Kapitel 1 — betrifft auch Kurse, nicht nur Klassen.
`SeatingPlanViewModelTests.ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan`
plus ein `CanExecute`-Test für die Bearbeitungsmodus-Sperre.
- [x] **7.1.6** Eigenständiges Fenster „Schüler ziehen“ im Menü Unterricht. Das Fenster ist
nicht modal und kann separat in einer Bildschirmfreigabe gezeigt werden; außerhalb der
eingeklappten Optionen zeigt es nur den aktuell gezogenen Namen. Auswahl wahlweise als
vollständige, zufällig gemischte Runde ohne Wiederholung oder intelligent gewichtet.
Optional fließen vorhandene Mitarbeitsdaten ein: selten aktive und bislang schwächere
Schüler erhalten ein höheres Gewicht, häufig aktive bzw. starke ein niedrigeres; direkte
Wiederholungen werden vermieden. Die Ziehhistorie bleibt rein im Arbeitsspeicher und wird
beim Schließen oder Zurücksetzen verworfen.
### 7.2 Gruppen
- [x] **7.2.1** Gruppe bearbeiten und löschen — bereits vorhanden (`EditGroupCommand`/`DeleteGroupCommand`/
`ToggleArchiveCommand` in `GroupListViewModel`), nicht Teil der aktuellen Klausuren-Arbeit,