Punkteeingabe: Korrekturmodus wählbar, Bonusaufgaben nicht mitnummeriert
CI / build-and-test (push) Canceled after 0s
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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user