This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user