Punkteeingabe: Korrekturmodus wählbar, Bonusaufgaben nicht mitnummeriert
CI / build-and-test (push) Canceled after 0s

- "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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 23:36:12 +02:00
1 parent 6c0a2dbd2e
commit d7898d5678
14 files changed
+419 -145

No files matched your search

@@ -0,0 +1,44 @@
using System.Text.Json;
namespace LehrerApp.Core.Services;
/// <summary>Reihenfolge, in der Enter bei der Punkteeingabe zum nächsten Feld springt.</summary>
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;
}
/// <summary>Merkt sich den zuletzt gewählten Korrekturmodus der Punkteeingabe lokal.</summary>
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<ExamGradingSettings>(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 */ }
}
}
@@ -10,6 +10,36 @@ public static class ExamScoringService
public static double Maximum(IEnumerable<ExamTask> tasks, ExamVariant variant) =>
tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints);
/// <summary>
/// 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", …). <see cref="ExamTask.Nr"/> bleibt davon
/// unberührt die reine Positions-/Sortiernummer.
/// </summary>
public static List<string> TaskNumbers(IEnumerable<bool> 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();
}
/// <summary>Beschriftung je Aufgabe ("Aufgabe 3", "3. Titel", "Bonus", "Bonus: Titel").</summary>
public static List<string> TaskLabels(IReadOnlyList<ExamTask> 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<ExamTask> tasks, IReadOnlyList<double> points,
ExamVariant variant) => tasks.Select((task, index) =>
AppliesTo(task, variant) && index < points.Count ? points[index] : 0).Sum();
@@ -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);
}
}
+1
View File
@@ -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<PlanningExchangeService>();
@@ -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;
@@ -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<ExamTask> 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<string> TaskHeaders { get; }
public ObservableCollection<ExamResultRow> 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
@@ -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;
@@ -38,7 +38,8 @@ public partial class ExamsOverviewView : UserControl
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<GradingService>(),
exam, group.Id);
exam, group.Id,
App.Services.GetRequiredService<ExamGradingSettingsService>());
var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -105,7 +105,7 @@
BorderThickness="1" CornerRadius="4" Padding="8" Margin="0,0,0,6">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="26,*,Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Text="{Binding Nr}" VerticalAlignment="Center" Opacity="0.6"/>
<TextBlock Grid.Column="0" Text="{Binding DisplayNr}" VerticalAlignment="Center" Opacity="0.6"/>
<TextBox Grid.Column="1" Text="{Binding Title}" PlaceholderText="Titel der Aufgabe" Margin="0,0,6,0"/>
<NumericUpDown Grid.Column="2" Value="{Binding MaxPoints}" Minimum="0" FormatString="0.##"
Width="72" ShowButtonSpinner="False" Margin="0,0,6,0" ToolTip.Tip="Maximalpunkte"/>
@@ -18,6 +18,10 @@
<Button Content="Rest als abwesend markieren" Command="{Binding MarkRemainingAbsentCommand}"
Margin="20,0,0,0" VerticalAlignment="Center"
ToolTip.Tip="Setzt bei allen noch leeren Zeilen 'Abwesend' — für Schüler, die nicht nachschreiben."/>
<TextBlock Text="Enter springt:" Margin="20,0,0,0" VerticalAlignment="Center" FontSize="13"/>
<ComboBox ItemsSource="{Binding EntryOrderOptions}" SelectedItem="{Binding EntryOrderName}"
Width="190" VerticalAlignment="Center"
ToolTip.Tip="Schüler für Schüler: eine Arbeit komplett, dann die nächste. Aufgabe für Aufgabe: eine Aufgabe bei allen, dann die nächste. Pfeiltasten bewegen sich immer direkt in die Richtung, Umschalt+Enter geht zurück."/>
</StackPanel>
<DataGrid Grid.Row="1" Name="ResultGrid"
@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Input;
@@ -9,7 +10,7 @@ using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LehrerApp.Desktop.ViewModels.Groups;
using System.Collections;
using System.ComponentModel;
using System.Globalization;
namespace LehrerApp.Desktop.Views.Groups;
@@ -17,21 +18,37 @@ namespace LehrerApp.Desktop.Views.Groups;
public partial class ExamGradingDialog : Window
{
private static readonly IBrush InvalidBackground = new SolidColorBrush(Color.Parse("#FFCDD2"));
private const string PointsCellName = "Pts";
private const string CommentCellName = "Comment";
public ExamGradingDialog() => InitializeComponent();
/// Aufgaben-Spalten in Aufgabenreihenfolge — nicht über einen festen Index-Offset in
/// grid.Columns auflösen, weil bei A/B-Arbeiten noch die "Fassung"-Spalte davor steht.
private readonly List<DataGridColumn> _taskColumns = [];
public ExamGradingDialog()
{
InitializeComponent();
// Tunnel statt KeyDown am TextBox: so kommt die Navigation vor TextBox und DataGrid an
// die Reihe. Vorher hat die DataGrid-eigene Tastenbehandlung Enter/Pfeile teilweise selbst
// ausgewertet (Sprung in die erste Zeile, Zellauswahl ohne offenes Eingabefeld).
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
}
private DataGrid? Grid => this.FindControl<DataGrid>("ResultGrid");
protected override void OnOpened(EventArgs e)
{
base.OnOpened(e);
if (DataContext is ExamGradingDialogViewModel vm) BuildColumns(vm);
if (DataContext is not ExamGradingDialogViewModel vm || Grid is not { } grid) return;
BuildColumns(vm, grid);
grid.CurrentCellChanged += (_, _) => OnGridCurrentCellChanged(vm, grid);
if (vm.FirstCell() is { } first) FocusPointsCell(vm, first.Row, first.Task);
}
private void BuildColumns(ExamGradingDialogViewModel vm)
private void BuildColumns(ExamGradingDialogViewModel vm, DataGrid grid)
{
var grid = this.FindControl<DataGrid>("ResultGrid");
if (grid is null) return;
grid.Columns.Clear();
_taskColumns.Clear();
grid.Columns.Add(new DataGridTextColumn
{
@@ -45,26 +62,26 @@ public partial class ExamGradingDialog : Window
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Fassung", Width = new DataGridLength(82, DataGridLengthUnitType.Pixel),
CellTemplate = new FuncDataTemplate<ExamResultRow>((row, _) =>
CellTemplate = new FuncDataTemplate<ExamResultRow>((_, _) =>
{
if (row is null) return new TextBlock();
var choice = new ComboBox { ItemsSource = row.VariantOptions, SelectedItem = row.VariantName };
choice.SelectionChanged += (_, _) => row.VariantName = choice.SelectedItem as string ?? "A";
var choice = new ComboBox();
choice.Bind(ItemsControl.ItemsSourceProperty, new Binding(nameof(ExamResultRow.VariantOptions)));
choice.Bind(SelectingItemsControl.SelectedItemProperty,
new Binding(nameof(ExamResultRow.VariantName)) { Mode = BindingMode.TwoWay });
return choice;
}),
});
for (var i = 0; i < vm.Tasks.Count; i++)
{
var task = vm.Tasks[i];
grid.Columns.Add(new DataGridTemplateColumn
var column = new DataGridTemplateColumn
{
Header = (string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}")
+ (task.Variant is null ? "" : $" ({task.Variant})")
+ (task.IsBonus ? " ★ Bonus" : ""),
Header = vm.TaskHeaders[i],
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
});
CellTemplate = BuildPointsCellTemplate(i),
};
_taskColumns.Add(column);
grid.Columns.Add(column);
}
grid.Columns.Add(new DataGridTextColumn
@@ -80,47 +97,76 @@ public partial class ExamGradingDialog : Window
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Abw.", Width = new DataGridLength(50, DataGridLengthUnitType.Pixel),
CellTemplate = BuildAbsentCellTemplate(),
CellTemplate = new FuncDataTemplate<ExamResultRow>((_, _) =>
{
var cb = new CheckBox { HorizontalAlignment = HorizontalAlignment.Center };
cb.Bind(ToggleButton.IsCheckedProperty,
new Binding(nameof(ExamResultRow.Absent)) { Mode = BindingMode.TwoWay });
return cb;
}),
});
grid.Columns.Add(new DataGridTemplateColumn
{
Header = "Kommentar", Width = new DataGridLength(180, DataGridLengthUnitType.Pixel),
CellTemplate = BuildCommentCellTemplate(grid),
CellTemplate = new FuncDataTemplate<ExamResultRow>((_, _) =>
{
var tb = new TextBox
{
Name = CommentCellName,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
};
tb.Bind(TextBox.TextProperty, new Binding(nameof(ExamResultRow.Comment))
{ Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.LostFocus });
return tb;
}),
});
}
private IDataTemplate BuildPointsCellTemplate(int taskIndex, DataGrid grid, int taskCount)
/// Die Zelle hängt sich bei jedem DataContext-Wechsel neu an die passende PointsCell, statt
/// die Zeile beim Erzeugen im Closure festzuhalten: Das DataGrid recycelt Zeilen beim
/// Scrollen, ein festgehaltener Verweis würde dann Punkte in die Zeile eines anderen
/// Schülers schreiben.
private static IDataTemplate BuildPointsCellTemplate(int taskIndex)
{
var cellName = $"Pts_{taskIndex}";
return new FuncDataTemplate<ExamResultRow>((row, _) =>
return new FuncDataTemplate<ExamResultRow>((_, _) =>
{
if (row is null) return new TextBlock();
var cell = row.Cells.ElementAtOrDefault(taskIndex);
if (cell is null) return new TextBlock();
var tb = new TextBox
{
Name = cellName,
Text = FormatPoints(cell.Value),
Name = PointsCellName,
Tag = taskIndex,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
HorizontalContentAlignment = HorizontalAlignment.Center,
IsEnabled = cell.IsApplicable,
};
ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
UpdateInvalidVisual(tb, cell.IsInvalid);
cell.PropertyChanged += (_, pe) =>
PointsCell? cell = null;
void OnCellPropertyChanged(object? _, PropertyChangedEventArgs pe)
{
if (pe.PropertyName == nameof(PointsCell.IsInvalid))
UpdateInvalidVisual(tb, cell.IsInvalid);
if (pe.PropertyName == nameof(PointsCell.IsApplicable))
tb.IsEnabled = cell.IsApplicable;
};
if (cell is null) return;
if (pe.PropertyName == nameof(PointsCell.IsInvalid)) UpdateInvalidVisual(tb, cell.IsInvalid);
if (pe.PropertyName == nameof(PointsCell.IsApplicable)) tb.IsEnabled = cell.IsApplicable;
}
tb.LostFocus += (_, _) => CommitPointsCell(tb, cell);
tb.KeyDown += (_, e) => HandlePointsCellKeyDown(e, grid, row, taskIndex, taskCount);
tb.DataContextChanged += (_, _) =>
{
if (cell is not null) cell.PropertyChanged -= OnCellPropertyChanged;
cell = (tb.DataContext as ExamResultRow)?.Cells.ElementAtOrDefault(taskIndex);
if (cell is null)
{
tb.Text = "";
tb.IsEnabled = false;
return;
}
cell.PropertyChanged += OnCellPropertyChanged;
tb.Text = FormatPoints(cell.Value);
tb.IsEnabled = cell.IsApplicable;
ToolTip.SetTip(tb, $"max. {FormatPoints(cell.MaxPoints)} Punkte");
UpdateInvalidVisual(tb, cell.IsInvalid);
};
tb.LostFocus += (_, _) => { if (cell is not null) CommitPointsCell(tb, cell); };
return tb;
});
@@ -142,119 +188,87 @@ public partial class ExamGradingDialog : Window
private static string FormatPoints(double? value) =>
value?.ToString("0.##", CultureInfo.InvariantCulture) ?? "";
private IDataTemplate BuildAbsentCellTemplate()
/// Zwei Korrektur-Workflows (Nutzer-Feedback), umschaltbar oben im Dialog: "Schüler für
/// Schüler" (Enter läuft die Zeile entlang, am Ende weiter zur nächsten Arbeit) und "Aufgabe
/// für Aufgabe" (Enter läuft die Spalte hinunter, am Ende weiter zur nächsten Aufgabe).
/// Umschalt+Enter geht in derselben Reihenfolge zurück. Pfeiltasten bewegen sich unabhängig
/// vom Modus direkt in ihre Richtung — links/rechts nur, wenn der Cursor am Rand steht oder
/// alles markiert ist, damit man innerhalb einer Zahl noch korrigieren kann.
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
return new FuncDataTemplate<ExamResultRow>((row, _) =>
{
if (row is null) return new TextBlock();
var cb = new CheckBox { IsChecked = row.Absent, HorizontalAlignment = HorizontalAlignment.Center };
cb.IsCheckedChanged += (_, _) => row.Absent = cb.IsChecked == true;
return cb;
});
}
private IDataTemplate BuildCommentCellTemplate(DataGrid grid)
{
const string cellName = "Comment";
return new FuncDataTemplate<ExamResultRow>((row, _) =>
{
if (row is null) return new TextBlock();
var tb = new TextBox
{
Name = cellName,
Text = row.Comment,
BorderThickness = new Thickness(0),
Background = Brushes.Transparent,
Padding = new Thickness(6, 4),
};
tb.LostFocus += (_, _) => row.Comment = tb.Text ?? "";
tb.KeyDown += (_, e) => HandleCommentCellKeyDown(e, grid, row, cellName);
return tb;
});
}
/// Zwei Korrektur-Workflows (Nutzer-Feedback): "Arbeit für Arbeit" (ein Schüler komplett,
/// dann der nächste) und "Aufgabe für Aufgabe" (eine Aufgabe für alle Schüler, dann die
/// nächste) — beide bewusst ohne Moduswechsel über zwei verschiedene Tasten, weil sich beide
/// Reihenfolgen ohnehin schon auf die Tabelle abbilden (Zeile vs. Spalte). Enter läuft
/// zeilenweise (am Zeilenende zurück zu Aufgabe 1 der nächsten Zeile), Pfeil-hoch/-runter
/// spaltenweise (am Spaltenende weiter zur Nachbaraufgabe) — beides bricht am Tabellenende
/// einfach ab, kein Rundlauf zurück zum Anfang.
private void HandlePointsCellKeyDown(KeyEventArgs e, DataGrid grid, ExamResultRow row, int taskIndex, int taskCount)
{
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true;
if (grid.ItemsSource is not IList items) return;
var rowIdx = items.IndexOf(row);
if (DataContext is not ExamGradingDialogViewModel vm) return;
if ((e.Source as Visual)?.FindAncestorOfType<TextBox>(includeSelf: true) is not { } tb) return;
if (tb.DataContext is not ExamResultRow row) return;
var rowIdx = vm.Rows.IndexOf(row);
if (rowIdx < 0) return;
int targetRowIdx;
int targetTaskIndex;
if (e.Key == Key.Enter)
if (tb.Name == PointsCellName && tb.Tag is int taskIdx)
{
targetTaskIndex = taskIndex + 1;
targetRowIdx = rowIdx;
if (targetTaskIndex >= taskCount) { targetTaskIndex = 0; targetRowIdx = rowIdx + 1; }
if (ToMove(e, tb) is not { } move) return;
e.Handled = true;
if (vm.NextCell(rowIdx, taskIdx, move) is { } target)
FocusPointsCell(vm, target.Row, target.Task);
}
else
else if (tb.Name == CommentCellName && e.Key is Key.Down or Key.Up or Key.Enter)
{
targetTaskIndex = taskIndex;
targetRowIdx = e.Key == Key.Up ? rowIdx - 1 : rowIdx + 1;
if (targetRowIdx < 0) { targetRowIdx = items.Count - 1; targetTaskIndex = taskIndex - 1; }
else if (targetRowIdx >= items.Count) { targetRowIdx = 0; targetTaskIndex = taskIndex + 1; }
e.Handled = true;
var targetIdx = e.Key == Key.Up || e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? rowIdx - 1 : rowIdx + 1;
if (targetIdx < 0 || targetIdx >= vm.Rows.Count || Grid is not { } grid) return;
FocusCell(grid, vm.Rows[targetIdx], grid.Columns[^1],
t => t.Name == CommentCellName);
}
if (targetTaskIndex < 0 || targetTaskIndex >= taskCount
|| targetRowIdx < 0 || targetRowIdx >= items.Count) return;
FocusPointsCell(grid, items[targetRowIdx]!, targetTaskIndex);
}
/// Enter/Pfeil-Hoch/Pfeil-Runter springen im Kommentarfeld zur gleichen Spalte in der
/// Nachbarzeile (Tab funktioniert bereits über die normale Fokus-Reihenfolge).
private void HandleCommentCellKeyDown(KeyEventArgs e, DataGrid grid, object rowItem, string cellName)
private static GradingMove? ToMove(KeyEventArgs e, TextBox tb)
{
if (e.Key is not (Key.Down or Key.Up or Key.Enter)) return;
e.Handled = true;
var len = tb.Text?.Length ?? 0;
var selStart = Math.Min(tb.SelectionStart, tb.SelectionEnd);
var selEnd = Math.Max(tb.SelectionStart, tb.SelectionEnd);
var allSelected = selStart == 0 && selEnd == len;
var noSelection = selStart == selEnd;
if (grid.ItemsSource is not IList items) return;
var idx = items.IndexOf(rowItem);
if (idx < 0) return;
var targetIdx = e.Key == Key.Up ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= items.Count) return;
var targetItem = items[targetIdx]!;
grid.ScrollIntoView(targetItem, grid.CurrentColumn);
Dispatcher.UIThread.Post(() =>
return e.Key switch
{
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
if (targetTb is null) return;
targetTb.Focus();
targetTb.SelectAll();
}, DispatcherPriority.Loaded);
Key.Enter when e.KeyModifiers.HasFlag(KeyModifiers.Shift) => GradingMove.Previous,
Key.Enter => GradingMove.Next,
Key.Up => GradingMove.Up,
Key.Down => GradingMove.Down,
Key.Left when allSelected || (noSelection && tb.CaretIndex == 0) => GradingMove.Left,
Key.Right when allSelected || (noSelection && tb.CaretIndex == len) => GradingMove.Right,
_ => null,
};
}
/// Spalte 0 ist "Schüler", danach folgen die Aufgaben-Spalten in Reihenfolge — daher der
/// Offset von 1 beim Auflösen des Spaltenobjekts für ScrollIntoView (muss auch horizontal
/// zur Nachbaraufgabe scrollen, nicht nur vertikal zur Nachbarzeile).
private void FocusPointsCell(DataGrid grid, object targetItem, int targetTaskIndex)
/// Navigiert das DataGrid selbst (z. B. nach Klick auf den Schülernamen per Pfeiltaste) in
/// eine Aufgabenzelle, wird deren Eingabefeld direkt geöffnet, statt nur die Zelle zu markieren.
private void OnGridCurrentCellChanged(ExamGradingDialogViewModel vm, DataGrid grid)
{
var targetColumn = grid.Columns.ElementAtOrDefault(1 + targetTaskIndex);
if (targetColumn is null) return;
grid.ScrollIntoView(targetItem, targetColumn);
if (FocusManager?.GetFocusedElement() is TextBox) return;
var taskIdx = grid.CurrentColumn is { } col ? _taskColumns.IndexOf(col) : -1;
if (taskIdx < 0 || grid.SelectedItem is not ExamResultRow row) return;
var rowIdx = vm.Rows.IndexOf(row);
if (vm.IsEditable(rowIdx, taskIdx)) FocusPointsCell(vm, rowIdx, taskIdx);
}
var cellName = $"Pts_{targetTaskIndex}";
private void FocusPointsCell(ExamGradingDialogViewModel vm, int rowIdx, int taskIdx)
{
if (Grid is not { } grid || taskIdx >= _taskColumns.Count) return;
FocusCell(grid, vm.Rows[rowIdx], _taskColumns[taskIdx],
t => t.Name == PointsCellName && t.Tag is int i && i == taskIdx);
}
/// Erst scrollen (auch horizontal), dann nach dem Layout-Durchlauf das TextBox-Element der
/// Zielzelle suchen — vorher existiert es bei virtualisierten Zeilen eventuell noch nicht.
private static void FocusCell(DataGrid grid, object rowItem, DataGridColumn column, Func<TextBox, bool> match)
{
grid.ScrollIntoView(rowItem, column);
Dispatcher.UIThread.Post(() =>
{
var targetTb = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == targetItem && t.Name == cellName);
if (targetTb is null) return;
targetTb.Focus();
targetTb.SelectAll();
var target = grid.GetVisualDescendants().OfType<TextBox>()
.FirstOrDefault(t => t.DataContext == rowItem && t.IsEffectivelyVisible && match(t));
if (target is null) return;
target.Focus(NavigationMethod.Tab);
target.SelectAll();
}, DispatcherPriority.Loaded);
}
@@ -281,7 +281,8 @@ public partial class GroupDetailView : UserControl
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<GradingService>(),
exam, vm.Group.Id);
exam, vm.Group.Id,
App.Services.GetRequiredService<ExamGradingSettingsService>());
var dialog = new ExamGradingDialog { DataContext = dialogVm };
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -23,6 +23,27 @@ public class ExamScoringServiceTests
Assert.Equal(20, ExamScoringService.Achieved(tasks, [10, 5, 8, 2], ExamVariant.B));
}
[Fact]
public void TaskLabels_ZählenBonusaufgabenNichtMit()
{
var tasks = new List<ExamTask>
{
new() { Nr = 1 },
new() { Nr = 2, IsBonus = true },
new() { Nr = 3, Title = "Titration" },
new() { Nr = 4, IsBonus = true, Title = "Knobelei" },
};
Assert.Equal(["1", "Bonus 1", "2", "Bonus 2"],
ExamScoringService.TaskNumbers(tasks.Select(t => t.IsBonus)));
Assert.Equal(["Aufgabe 1", "Bonus 1", "2. Titration", "Bonus 2: Knobelei"],
ExamScoringService.TaskLabels(tasks));
}
[Fact]
public void TaskNumbers_EinzelneBonusaufgabeHeisstNurBonus() =>
Assert.Equal(["1", "Bonus", "2"], ExamScoringService.TaskNumbers([false, true, false]));
[Theory]
[InlineData(RepeatGradeRule.Better, GradingSystem.Grades1To6, "2", "4", "2")]
[InlineData(RepeatGradeRule.Better, GradingSystem.Points0To15, "8", "11", "11")]
+12
View File
@@ -92,6 +92,18 @@ pragmatisch über "Abwesend" bei den übrigen Schülern statt über eine eigene
scrollt dabei automatisch auch horizontal mit) — für "Aufgabe für Aufgabe". Beides bricht
am Tabellenende einfach ab, kein Rundlauf zurück zum Anfang. Direkte Zifferneingabe und
halbe Punkte (Komma oder Punkt) weiterhin erlaubt.
**Nachtrag 2026-10 (Nutzer-Feedback: Enter sprang in die erste Zeile, Pfeiltasten öffneten
das Eingabefeld nicht):** Der Korrekturmodus ist jetzt oben im Dialog wählbar („Enter
springt: Schüler für Schüler / Aufgabe für Aufgabe“) und wird in
`examgradingsettings.json` gemerkt. Enter folgt dem Modus, Umschalt+Enter geht zurück;
Pfeiltasten bewegen sich immer direkt in ihre Richtung (links/rechts nur am Textrand oder
bei voller Markierung). Die Tasten werden per Tunnel-Handler am Fenster abgefangen, bevor
DataGrid/TextBox sie selbst auswerten; Ziel ist immer das Eingabefeld, abwesende Schüler
und Aufgaben der anderen Fassung werden übersprungen. Beim Öffnen steht der Cursor im
ersten Feld. Zellen lesen ihre Zeile aus dem DataContext statt aus einem Closure, damit
recycelte DataGrid-Zeilen keine Punkte in die falsche Zeile schreiben.
Bonusaufgaben zählen bei der angezeigten Nummerierung nicht mit (Editor, Punkteeingabe,
Auswertung: „Bonus“ bzw. „Bonus 1/2“), `ExamTask.Nr` bleibt reine Sortiernummer.
- [x] **1.4.3** Kennzeichnung "Abwesend" (`ExamResult.Absent`) per Checkbox — Note zeigt "abwesend"
statt einer berechneten Note. Ausschluss aus Statistiken über 1.5 umgesetzt.
- [x] **1.4.4** Kommentarfeld pro Schüler (`ExamResult.Comment`).