Klausurvarianten, Bonuspunkte und Wiederholungen ergänzen
This commit is contained in:
1 parent
54ad7f7419
commit
431d023c1a
22 files changed
+522
-40
No files matched your search
@@ -22,6 +22,9 @@ public class Exam
|
||||
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
|
||||
/// (siehe ExamDialogViewModel.Save).
|
||||
public Guid? SharedExamGroupId { get; set; }
|
||||
/// <summary>Optional verknüpfte ursprüngliche Arbeit derselben Lerngruppe.</summary>
|
||||
public Guid? RepeatOfExamId { get; set; }
|
||||
public RepeatGradeRule RepeatGradeRule { get; set; } = RepeatGradeRule.Better;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -31,6 +34,9 @@ public class ExamTask
|
||||
public string? Title { get; set; }
|
||||
public double MaxPoints { get; set; }
|
||||
public double Weight { get; set; } = 1.0;
|
||||
/// <summary>Null gilt für beide Fassungen; A/B nur für die gewählte Fassung.</summary>
|
||||
public ExamVariant? Variant { get; set; }
|
||||
public bool IsBonus { get; set; }
|
||||
public List<CompetencyReference> Competencies { get; set; } = [];
|
||||
}
|
||||
public class GradingKeyEntry
|
||||
@@ -55,7 +61,10 @@ public class ExamResult
|
||||
public double TotalPoints { get; set; }
|
||||
public string? Grade { get; set; }
|
||||
public bool Absent { get; set; }
|
||||
public ExamVariant Variant { get; set; } = ExamVariant.A;
|
||||
public string? Comment { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
public enum ExamStatus { Planned, Conducted, Graded, Returned }
|
||||
public enum ExamVariant { A, B }
|
||||
public enum RepeatGradeRule { Better, Repeat, Original }
|
||||
@@ -0,0 +1,65 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public static class ExamScoringService
|
||||
{
|
||||
public static bool AppliesTo(ExamTask task, ExamVariant variant) =>
|
||||
task.Variant is null || task.Variant == variant;
|
||||
|
||||
public static double Maximum(IEnumerable<ExamTask> tasks, ExamVariant variant) =>
|
||||
tasks.Where(t => AppliesTo(t, variant) && !t.IsBonus).Sum(t => t.MaxPoints);
|
||||
|
||||
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();
|
||||
|
||||
/// <summary>Wählt pro verknüpfter Wiederholung genau eine Note. Andere Arbeiten bleiben erhalten.</summary>
|
||||
public static List<string> EffectiveGrades(IEnumerable<(Exam Exam, string Grade)> entries,
|
||||
GradingSystem system)
|
||||
{
|
||||
var available = entries.ToList();
|
||||
var byId = available.ToDictionary(x => x.Exam.Id);
|
||||
var consumed = new HashSet<Guid>();
|
||||
var result = new List<string>();
|
||||
foreach (var entry in available)
|
||||
{
|
||||
if (!consumed.Add(entry.Exam.Id)) continue;
|
||||
if (entry.Exam.RepeatOfExamId is { } originalId && byId.TryGetValue(originalId, out var original))
|
||||
{
|
||||
consumed.Add(originalId);
|
||||
result.Add(Choose(original.Grade, entry.Grade, entry.Exam.RepeatGradeRule, system));
|
||||
}
|
||||
else if (available.FirstOrDefault(x => x.Exam.RepeatOfExamId == entry.Exam.Id) is var repeat
|
||||
&& repeat.Exam is not null)
|
||||
{
|
||||
consumed.Add(repeat.Exam.Id);
|
||||
result.Add(Choose(entry.Grade, repeat.Grade, repeat.Exam.RepeatGradeRule, system));
|
||||
}
|
||||
else result.Add(entry.Grade);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Choose(string original, string repeat, RepeatGradeRule rule, GradingSystem system)
|
||||
{
|
||||
if (rule == RepeatGradeRule.Original) return original;
|
||||
if (rule == RepeatGradeRule.Repeat) return repeat;
|
||||
var first = GradeValue(original, system);
|
||||
var second = GradeValue(repeat, system);
|
||||
if (first is null || second is null) return original;
|
||||
return system == GradingSystem.Grades1To6
|
||||
? (second < first ? repeat : original)
|
||||
: (second > first ? repeat : original);
|
||||
}
|
||||
|
||||
private static double? GradeValue(string grade, GradingSystem system)
|
||||
{
|
||||
var core = grade.Trim();
|
||||
var tendency = 0.0;
|
||||
if (core.EndsWith('+')) { tendency = system == GradingSystem.Grades1To6 ? -1.0 / 3 : 1.0 / 3; core = core[..^1]; }
|
||||
else if (core.EndsWith('-')) { tendency = system == GradingSystem.Grades1To6 ? 1.0 / 3 : -1.0 / 3; core = core[..^1]; }
|
||||
return double.TryParse(core.Trim(), System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var value) ? value + tendency : null;
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,27 @@ public sealed class ExamDialogViewModelTests
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BonusNachtraeglichMarkiert_BerechnetGespeichertesErgebnisNeu()
|
||||
{
|
||||
var exam = MakeExam(new DateOnly(2026, 5, 12));
|
||||
exam.Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }, new ExamTask { Nr = 2, MaxPoints = 2 }];
|
||||
exam.GradingKey = GradingService.DefaultKey1To6();
|
||||
var result = new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(),
|
||||
Points = [8, 2], TotalPoints = 10, Grade = "2" };
|
||||
var results = new FakeResults();
|
||||
results.Add(result);
|
||||
var vm = new ExamDialogViewModel(new FakeExams([exam]), new FakeCompetencyDomains(),
|
||||
new FakeGradingKeyTemplates(), new GradingService(), exam.GroupId, null, 9,
|
||||
GradingSystem.Grades1To6, "Chemie", false, exam, null,
|
||||
results: results);
|
||||
vm.Tasks[1].IsBonus = true;
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(exam.Tasks[1].IsBonus);
|
||||
Assert.Equal(10, result.TotalPoints);
|
||||
Assert.Equal("1", result.Grade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public class ExamVariantScoringTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fassungswechsel_BerechnetPunkteUndNoteNeu()
|
||||
{
|
||||
var tasks = new List<ExamTask>
|
||||
{
|
||||
new() { Nr = 1, MaxPoints = 10 },
|
||||
new() { Nr = 2, MaxPoints = 5, Variant = ExamVariant.A },
|
||||
new() { Nr = 3, MaxPoints = 8, Variant = ExamVariant.B },
|
||||
new() { Nr = 4, MaxPoints = 2, IsBonus = true },
|
||||
};
|
||||
var existing = new ExamResult { Points = [8, 5, 8, 2] };
|
||||
var row = new ExamResultRow(Guid.NewGuid(), "Test", tasks, existing,
|
||||
GradingService.DefaultKey1To6(), 15, new GradingService());
|
||||
|
||||
Assert.Equal(15, row.TotalPoints);
|
||||
Assert.False(row.Cells[2].IsApplicable);
|
||||
|
||||
row.VariantName = "B";
|
||||
|
||||
Assert.Equal(18, row.TotalPoints);
|
||||
Assert.False(row.Cells[1].IsApplicable);
|
||||
Assert.True(row.Cells[2].IsApplicable);
|
||||
Assert.Equal(ExamVariant.B, row.ToModel(Guid.NewGuid()).Variant);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
private readonly GradingService _grading;
|
||||
private readonly Exam _exam;
|
||||
private readonly GradingSystem _gradingSystem;
|
||||
private readonly IStudentRepository? _students;
|
||||
private readonly double _examMaxPoints;
|
||||
private List<ExamResult> _gradedResults = [];
|
||||
|
||||
@@ -38,19 +39,63 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<GradeBarItem> GradeDistribution { get; } = [];
|
||||
public ObservableCollection<TaskAnalysisItem> TaskAnalysis { get; } = [];
|
||||
public ObservableCollection<RepeatComparisonItem> RepeatComparison { get; } = [];
|
||||
public bool HasRepeatComparison => RepeatComparison.Count > 0;
|
||||
public string RepeatComparisonTitle { get; private set; } = "";
|
||||
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
|
||||
|
||||
public ExamEvaluationDialogViewModel(IExamRepository exams, IExamResultRepository results,
|
||||
GradingService grading, Exam exam, GradingSystem gradingSystem)
|
||||
GradingService grading, Exam exam, GradingSystem gradingSystem,
|
||||
IStudentRepository? students = null)
|
||||
{
|
||||
_exams = exams; _results = results; _grading = grading; _exam = exam;
|
||||
_gradingSystem = gradingSystem;
|
||||
_examMaxPoints = exam.Tasks.Sum(t => t.MaxPoints);
|
||||
_gradingSystem = gradingSystem; _students = students;
|
||||
_examMaxPoints = ExamScoringService.Maximum(exam.Tasks, ExamVariant.A);
|
||||
|
||||
foreach (var e in exam.GradingKey.OrderByDescending(e => e.MinPercent))
|
||||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||||
|
||||
LoadResults();
|
||||
LoadRepeatComparison(students);
|
||||
}
|
||||
|
||||
private void LoadRepeatComparison(IStudentRepository? students)
|
||||
{
|
||||
RepeatComparison.Clear();
|
||||
RepeatComparisonTitle = "";
|
||||
var original = _exam.RepeatOfExamId is { } originalId ? _exams.GetById(originalId) : _exam;
|
||||
if (original is null) return;
|
||||
var repeat = _exam.RepeatOfExamId is not null ? _exam
|
||||
: _exams.GetByGroup(_exam.GroupId).FirstOrDefault(e => e.RepeatOfExamId == _exam.Id);
|
||||
if (repeat is null) return;
|
||||
var rule = repeat.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
RepeatGradeRule.Original => "Erste Arbeit zählt",
|
||||
_ => "Bessere zählt",
|
||||
};
|
||||
RepeatComparisonTitle = $"Erste Arbeit: {original.Title} · Wiederholung: {repeat.Title} · {rule}";
|
||||
var first = _results.GetByExam(original.Id).Where(r => !r.Absent && r.Grade is not null)
|
||||
.ToDictionary(r => r.StudentId);
|
||||
var second = _results.GetByExam(repeat.Id).Where(r => !r.Absent && r.Grade is not null)
|
||||
.ToDictionary(r => r.StudentId);
|
||||
var names = students?.GetByGroup(_exam.GroupId).ToDictionary(s => s.Id, s => s.FullName);
|
||||
foreach (var studentId in first.Keys.Union(second.Keys)
|
||||
.OrderBy(id => names?.GetValueOrDefault(id) ?? id.ToString()))
|
||||
{
|
||||
first.TryGetValue(studentId, out var originalResult);
|
||||
second.TryGetValue(studentId, out var repeatResult);
|
||||
var entries = new List<(Exam Exam, string Grade)>();
|
||||
if (originalResult?.Grade is { } g1) entries.Add((original, g1));
|
||||
if (repeatResult?.Grade is { } g2) entries.Add((repeat, g2));
|
||||
var effective = ExamScoringService.EffectiveGrades(entries, _gradingSystem);
|
||||
RepeatComparison.Add(new RepeatComparisonItem(
|
||||
names?.GetValueOrDefault(studentId) ?? studentId.ToString(),
|
||||
originalResult?.Grade ?? "–", repeatResult?.Grade ?? "–",
|
||||
effective.FirstOrDefault() ?? "–"));
|
||||
}
|
||||
OnPropertyChanged(nameof(HasRepeatComparison));
|
||||
OnPropertyChanged(nameof(RepeatComparisonTitle));
|
||||
}
|
||||
|
||||
private void LoadResults()
|
||||
@@ -98,8 +143,11 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
foreach (var e in GradingKeyEntries)
|
||||
e.AbsolutePointsDisplay = _examMaxPoints <= 0
|
||||
? "–"
|
||||
: $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
|
||||
$"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
|
||||
: _exam.Tasks.Any(t => t.Variant is not null)
|
||||
? $"A ab {e.MinPercent / 100.0 * _examMaxPoints:0.##} / " +
|
||||
$"B ab {e.MinPercent / 100.0 * ExamScoringService.Maximum(_exam.Tasks, ExamVariant.B):0.##} Punkten"
|
||||
: $"ab {(e.MinPercent / 100.0 * _examMaxPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
|
||||
$"von {_examMaxPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
|
||||
}
|
||||
|
||||
private List<GradingKeyEntry> BuildCurrentGradingKey() => GradingKeyEntries
|
||||
@@ -116,6 +164,12 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
GradingKeyValidation = "";
|
||||
_exam.GradingKey = key;
|
||||
_exams.Save(_exam);
|
||||
var results = _results.GetByExam(_exam.Id);
|
||||
foreach (var result in results.Where(r => !r.Absent))
|
||||
result.Grade = _grading.CalculateGrade(result.TotalPoints,
|
||||
ExamScoringService.Maximum(_exam.Tasks, result.Variant), key);
|
||||
_results.SaveMany(results);
|
||||
LoadRepeatComparison(_students);
|
||||
GradingKeyStatus = "Notenschlüssel für diese Klausur übernommen.";
|
||||
}
|
||||
|
||||
@@ -127,7 +181,8 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
GradingKeyValidation = _grading.ValidateGradingKey(currentKey) ?? "";
|
||||
|
||||
var grades = _gradedResults
|
||||
.Select(r => _grading.CalculateGrade(r.TotalPoints, _examMaxPoints, currentKey))
|
||||
.Select(r => _grading.CalculateGrade(r.TotalPoints,
|
||||
ExamScoringService.Maximum(_exam.Tasks, r.Variant), currentKey))
|
||||
.ToList();
|
||||
|
||||
GradeDistribution.Clear();
|
||||
@@ -174,11 +229,14 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
for (var i = 0; i < tasks.Count; i++)
|
||||
{
|
||||
var task = tasks[i];
|
||||
var achieved = _gradedResults.Where(r => i < r.Points.Count).Select(r => r.Points[i]).ToList();
|
||||
var achieved = _gradedResults.Where(r => i < r.Points.Count &&
|
||||
ExamScoringService.AppliesTo(task, r.Variant)).Select(r => r.Points[i]).ToList();
|
||||
var avgPercent = task.MaxPoints <= 0 || achieved.Count == 0
|
||||
? 0
|
||||
: achieved.Average() / task.MaxPoints * 100.0;
|
||||
TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, task.Title, avgPercent));
|
||||
var suffix = (task.Variant is null ? "" : $" ({task.Variant})")
|
||||
+ (task.IsBonus ? " · Bonus" : "");
|
||||
TaskAnalysis.Add(new TaskAnalysisItem(task.Nr, (task.Title ?? "") + suffix, avgPercent));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,8 +267,15 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
if (!string.IsNullOrEmpty(BelowThresholdLabel1)) csv.AddRow(BelowThresholdLabel1, BelowThresholdDisplay1);
|
||||
if (!string.IsNullOrEmpty(BelowThresholdLabel2)) csv.AddRow(BelowThresholdLabel2, BelowThresholdDisplay2);
|
||||
csv.AddRow("Bewertet", GradedCount)
|
||||
.AddRow("Abwesend", AbsentCount)
|
||||
.AddBlankRow()
|
||||
.AddRow("Abwesend", AbsentCount);
|
||||
if (HasRepeatComparison)
|
||||
{
|
||||
csv.AddBlankRow().AddRow("Vergleich", RepeatComparisonTitle)
|
||||
.AddRow("Schüler", "Erste Arbeit", "Wiederholung", "Zählt");
|
||||
foreach (var item in RepeatComparison)
|
||||
csv.AddRow(item.StudentName, item.OriginalGrade, item.RepeatGrade, item.EffectiveGrade);
|
||||
}
|
||||
csv.AddBlankRow()
|
||||
.AddRow("Aufgabenanalyse")
|
||||
.AddRow("Aufgabe", "Ø Erfüllungsgrad", "Auffällig schwach");
|
||||
foreach (var t in TaskAnalysis)
|
||||
@@ -221,6 +286,9 @@ public partial class ExamEvaluationDialogViewModel : ObservableObject
|
||||
|
||||
// ── Notenspiegel-Balken ────────────────────────────────────────────────────
|
||||
|
||||
public record RepeatComparisonItem(string StudentName, string OriginalGrade,
|
||||
string RepeatGrade, string EffectiveGrade);
|
||||
|
||||
public class GradeBarItem
|
||||
{
|
||||
public string Grade { get; }
|
||||
|
||||
@@ -15,12 +15,12 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
private readonly IExamResultRepository _results;
|
||||
private readonly GradingService _grading;
|
||||
private readonly Exam _exam;
|
||||
private readonly double _examMaxPoints;
|
||||
|
||||
public string ExamTitle => _exam.Title;
|
||||
public string ExamDateLabel => _exam.Date.ToString("dd.MM.yyyy");
|
||||
public string NiveauLabel => _exam.Niveau.HasValue ? NiveauDisplay.ToName(_exam.Niveau) : "";
|
||||
public List<ExamTask> Tasks { get; }
|
||||
public bool HasVariants => Tasks.Any(t => t.Variant is not null);
|
||||
|
||||
public ObservableCollection<ExamResultRow> Rows { get; } = [];
|
||||
|
||||
@@ -29,7 +29,6 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
{
|
||||
_results = results; _grading = grading; _exam = exam;
|
||||
Tasks = exam.Tasks.OrderBy(t => t.Nr).ToList();
|
||||
_examMaxPoints = Tasks.Sum(t => t.MaxPoints);
|
||||
|
||||
var enrolled = students.GetByGroup(groupId);
|
||||
var membershipList = memberships.GetByGroup(groupId);
|
||||
@@ -45,7 +44,8 @@ public partial class ExamGradingDialogViewModel : ObservableObject
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
|
||||
existing.TryGetValue(s.Id, out var result);
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey, _examMaxPoints, _grading);
|
||||
var row = new ExamResultRow(s.Id, s.FullName, Tasks, result, _exam.GradingKey,
|
||||
ExamScoringService.Maximum(Tasks, ExamVariant.A), _grading);
|
||||
row.OnChanged = SaveRow;
|
||||
Rows.Add(row);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ public partial class ExamResultRow : ObservableObject
|
||||
private readonly Guid _resultId;
|
||||
private readonly List<GradingKeyEntry> _gradingKey;
|
||||
private readonly double _examMaxPoints;
|
||||
private readonly List<ExamTask> _tasks;
|
||||
private readonly GradingService _grading;
|
||||
|
||||
public Guid StudentId { get; }
|
||||
@@ -83,6 +84,9 @@ public partial class ExamResultRow : ObservableObject
|
||||
[ObservableProperty] private string _comment = "";
|
||||
[ObservableProperty] private double _totalPoints;
|
||||
[ObservableProperty] private string _gradeDisplay = "";
|
||||
[ObservableProperty] private string _variantName = "A";
|
||||
public string[] VariantOptions => ["A", "B"];
|
||||
private ExamVariant Variant => VariantName == "B" ? ExamVariant.B : ExamVariant.A;
|
||||
|
||||
public string TotalPointsDisplay => TotalPoints.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
@@ -92,15 +96,19 @@ public partial class ExamResultRow : ObservableObject
|
||||
List<GradingKeyEntry> gradingKey, double examMaxPoints, GradingService grading)
|
||||
{
|
||||
StudentId = studentId; Name = name;
|
||||
_gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading;
|
||||
_gradingKey = gradingKey; _examMaxPoints = examMaxPoints; _grading = grading; _tasks = tasks;
|
||||
_resultId = existing?.Id ?? Guid.NewGuid();
|
||||
_absent = existing?.Absent ?? false;
|
||||
_comment = existing?.Comment ?? "";
|
||||
_variantName = existing?.Variant.ToString() ?? "A";
|
||||
|
||||
for (var i = 0; i < tasks.Count; i++)
|
||||
{
|
||||
double? pts = existing is not null && i < existing.Points.Count ? existing.Points[i] : null;
|
||||
var cell = new PointsCell(tasks[i].MaxPoints, pts);
|
||||
var cell = new PointsCell(tasks[i].MaxPoints, pts)
|
||||
{
|
||||
IsApplicable = ExamScoringService.AppliesTo(tasks[i], Variant),
|
||||
};
|
||||
cell.OnChanged = () => { RecomputeTotals(); OnChanged?.Invoke(this); };
|
||||
Cells.Add(cell);
|
||||
}
|
||||
@@ -114,12 +122,20 @@ public partial class ExamResultRow : ObservableObject
|
||||
}
|
||||
|
||||
partial void OnCommentChanged(string value) => OnChanged?.Invoke(this);
|
||||
partial void OnVariantNameChanged(string value)
|
||||
{
|
||||
for (var i = 0; i < Cells.Count; i++)
|
||||
Cells[i].IsApplicable = ExamScoringService.AppliesTo(_tasks[i], Variant);
|
||||
RecomputeTotals();
|
||||
OnChanged?.Invoke(this);
|
||||
}
|
||||
|
||||
private void RecomputeTotals()
|
||||
{
|
||||
TotalPoints = Cells.Sum(c => c.IsInvalid ? 0 : (c.Value ?? 0));
|
||||
TotalPoints = Cells.Sum(c => c.IsApplicable && !c.IsInvalid ? (c.Value ?? 0) : 0);
|
||||
OnPropertyChanged(nameof(TotalPointsDisplay));
|
||||
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints, _examMaxPoints, _gradingKey);
|
||||
GradeDisplay = Absent ? "abwesend" : _grading.CalculateGrade(TotalPoints,
|
||||
ExamScoringService.Maximum(_tasks, Variant), _gradingKey);
|
||||
}
|
||||
|
||||
public ExamResult ToModel(Guid examId) => new()
|
||||
@@ -131,6 +147,7 @@ public partial class ExamResultRow : ObservableObject
|
||||
TotalPoints = TotalPoints,
|
||||
Grade = Absent ? null : GradeDisplay,
|
||||
Absent = Absent,
|
||||
Variant = Variant,
|
||||
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||
};
|
||||
}
|
||||
@@ -142,6 +159,7 @@ public partial class PointsCell : ObservableObject
|
||||
public double MaxPoints { get; }
|
||||
[ObservableProperty] private double? _value;
|
||||
[ObservableProperty] private bool _isInvalid;
|
||||
[ObservableProperty] private bool _isApplicable = true;
|
||||
|
||||
public Action? OnChanged { get; set; }
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||
public partial class ExamDialogViewModel : ObservableObject
|
||||
{
|
||||
private readonly IExamRepository _exams;
|
||||
private readonly IExamResultRepository? _results;
|
||||
private readonly ICompetencyDomainRepository _competencyDomains;
|
||||
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
||||
private readonly GradingService _grading;
|
||||
@@ -41,13 +42,21 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
[ObservableProperty] private string _newTemplateName = "";
|
||||
[ObservableProperty] private string _gradingKeyValidation = "";
|
||||
[ObservableProperty] private string _selectedNiveauName = "–";
|
||||
[ObservableProperty] private Exam? _selectedOriginalExam;
|
||||
[ObservableProperty] private string _repeatRuleName = "Bessere zählt";
|
||||
[ObservableProperty] private string _repeatError = "";
|
||||
[ObservableProperty] private string _taskPointsError = "";
|
||||
|
||||
public bool IsDifferentiated { get; }
|
||||
public string[] NiveauOptions => NiveauDisplay.Options;
|
||||
public string[] RepeatRuleOptions => ["Bessere zählt", "Wiederholung zählt", "Erste Arbeit zählt"];
|
||||
public bool HasSelectedOriginalExam => SelectedOriginalExam is not null;
|
||||
public ObservableCollection<Exam> AvailableOriginalExams { get; } = [];
|
||||
|
||||
public bool TotalPointsWarning => TotalPoints <= 0;
|
||||
public string TotalPointsDisplay =>
|
||||
$"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt";
|
||||
public string TotalPointsDisplay => Tasks.Any(t => t.VariantName != "Beide")
|
||||
? $"A: {TotalPoints:0.##} · B: {Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkte (ohne Bonus)"
|
||||
: $"{TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkte gesamt (ohne Bonus)";
|
||||
|
||||
public ObservableCollection<ExamTaskEditItem> Tasks { get; } = [];
|
||||
public ObservableCollection<GradingKeyEntryEditItem> GradingKeyEntries { get; } = [];
|
||||
@@ -65,9 +74,10 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
IGradingKeyTemplateRepository gradingKeyTemplates, GradingService grading,
|
||||
Guid groupId, Guid? subjectId, int gradeLevel, GradingSystem gradingSystem,
|
||||
string defaultSubjectName, bool isDifferentiated, Exam? editingExam, Exam? duplicateSource,
|
||||
Guid competencyCatalogVersionId = default)
|
||||
Guid competencyCatalogVersionId = default, IExamResultRepository? results = null)
|
||||
{
|
||||
_exams = exams; _competencyDomains = competencyDomains;
|
||||
_results = results;
|
||||
_gradingKeyTemplates = gradingKeyTemplates; _grading = grading;
|
||||
_groupId = groupId; _subjectId = subjectId; _gradeLevel = gradeLevel;
|
||||
_competencyCatalogVersionId = competencyCatalogVersionId;
|
||||
@@ -76,6 +86,12 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
_duplicateSource = duplicateSource;
|
||||
_subjectName = defaultSubjectName;
|
||||
IsDifferentiated = isDifferentiated;
|
||||
var groupExams = _exams.GetByGroup(groupId);
|
||||
foreach (var exam in groupExams
|
||||
.Where(e => e.Id != editingExam?.Id && e.RepeatOfExamId is null)
|
||||
.Where(e => !groupExams.Any(r => r.RepeatOfExamId == e.Id && r.Id != editingExam?.Id))
|
||||
.OrderByDescending(e => e.Date))
|
||||
AvailableOriginalExams.Add(exam);
|
||||
|
||||
HasCompetencyCatalog = subjectId.HasValue
|
||||
&& _competencyDomains.GetBySubjectAndGrade(subjectId.Value, gradeLevel, competencyCatalogVersionId).Count > 0;
|
||||
@@ -97,8 +113,18 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
// Beim Duplizieren bewusst kein Niveau übernehmen — meist wird dupliziert, um
|
||||
// gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen.
|
||||
SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau);
|
||||
if (!isDuplicate)
|
||||
{
|
||||
SelectedOriginalExam = AvailableOriginalExams.FirstOrDefault(e => e.Id == source.RepeatOfExamId);
|
||||
RepeatRuleName = source.RepeatGradeRule switch
|
||||
{
|
||||
RepeatGradeRule.Repeat => "Wiederholung zählt",
|
||||
RepeatGradeRule.Original => "Erste Arbeit zählt",
|
||||
_ => "Bessere zählt",
|
||||
};
|
||||
}
|
||||
foreach (var t in source.Tasks.OrderBy(t => t.Nr))
|
||||
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies]);
|
||||
AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies], t.Variant, t.IsBonus);
|
||||
UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001);
|
||||
foreach (var e in source.GradingKey.OrderByDescending(e => e.MinPercent))
|
||||
AddGradingKeyRowInternal(e.Grade, e.MinPercent);
|
||||
@@ -119,6 +145,9 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
{
|
||||
foreach (var t in Tasks) t.ShowWeight = value;
|
||||
}
|
||||
partial void OnSelectedOriginalExamChanged(Exam? value) => OnPropertyChanged(nameof(HasSelectedOriginalExam));
|
||||
|
||||
[RelayCommand] private void ClearOriginalExam() => SelectedOriginalExam = null;
|
||||
|
||||
partial void OnDateTextChanged(string value) => UpdateWeekLoadHint();
|
||||
|
||||
@@ -148,11 +177,14 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
RecomputeTotals();
|
||||
}
|
||||
|
||||
private void AddTaskInternal(string? title, double maxPoints, double weight, List<CompetencyReference> competencies)
|
||||
private void AddTaskInternal(string? title, double maxPoints, double weight, List<CompetencyReference> competencies,
|
||||
ExamVariant? variant = null, bool isBonus = false)
|
||||
{
|
||||
var item = new ExamTaskEditItem(title, maxPoints, weight, competencies,
|
||||
BuildCompetencyTagGroups(competencies))
|
||||
{
|
||||
VariantName = variant?.ToString() ?? "Beide",
|
||||
IsBonus = isBonus,
|
||||
OnChanged = RecomputeTotals,
|
||||
OnRemove = RemoveTask,
|
||||
OnMoveUp = MoveTaskUp,
|
||||
@@ -201,7 +233,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
|
||||
private void RecomputeTotals()
|
||||
{
|
||||
TotalPoints = Tasks.Sum(t => t.MaxPoints);
|
||||
TotalPoints = Tasks.Where(t => !t.IsBonus && t.VariantName != "B").Sum(t => t.MaxPoints);
|
||||
OnPropertyChanged(nameof(TotalPointsWarning));
|
||||
OnPropertyChanged(nameof(TotalPointsDisplay));
|
||||
RecomputeGradingKeyAbsolutes();
|
||||
@@ -214,6 +246,9 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
foreach (var e in GradingKeyEntries)
|
||||
e.AbsolutePointsDisplay = TotalPoints <= 0
|
||||
? "–"
|
||||
: Tasks.Any(t => t.VariantName != "Beide")
|
||||
? $"A ab {e.MinPercent / 100.0 * TotalPoints:0.##} / " +
|
||||
$"B ab {e.MinPercent / 100.0 * Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints):0.##} Punkten"
|
||||
: $"ab {(e.MinPercent / 100.0 * TotalPoints).ToString("0.##", CultureInfo.InvariantCulture)} " +
|
||||
$"von {TotalPoints.ToString("0.##", CultureInfo.InvariantCulture)} Punkten";
|
||||
}
|
||||
@@ -288,6 +323,7 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
private void Save()
|
||||
{
|
||||
TitleError = ""; DateTextError = ""; ReturnedAtTextError = ""; GradingKeyValidation = "";
|
||||
RepeatError = ""; TaskPointsError = "";
|
||||
var valid = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
|
||||
@@ -313,6 +349,17 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
var gradingKey = BuildGradingKeyEntries();
|
||||
var gradingKeyError = _grading.ValidateGradingKey(gradingKey);
|
||||
if (gradingKeyError is not null) { GradingKeyValidation = gradingKeyError; valid = false; }
|
||||
if (SelectedOriginalExam is not null && SelectedOriginalExam.Date >= date)
|
||||
{
|
||||
RepeatError = "Die erste Arbeit muss vor der Wiederholung liegen.";
|
||||
valid = false;
|
||||
}
|
||||
if (Tasks.Any(t => t.VariantName != "Beide") &&
|
||||
(TotalPoints <= 0 || Tasks.Where(t => !t.IsBonus && t.VariantName != "A").Sum(t => t.MaxPoints) <= 0))
|
||||
{
|
||||
TaskPointsError = "Fassung A und B brauchen jeweils Grundpunkte.";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) return;
|
||||
|
||||
@@ -325,12 +372,31 @@ public partial class ExamDialogViewModel : ObservableObject
|
||||
Result.Niveau = NiveauDisplay.FromName(SelectedNiveauName);
|
||||
Result.Tasks = Tasks.Select(t => t.ToModel()).ToList();
|
||||
Result.GradingKey = gradingKey;
|
||||
Result.RepeatOfExamId = SelectedOriginalExam?.Id;
|
||||
Result.RepeatGradeRule = RepeatRuleName switch
|
||||
{
|
||||
"Wiederholung zählt" => RepeatGradeRule.Repeat,
|
||||
"Erste Arbeit zählt" => RepeatGradeRule.Original,
|
||||
_ => RepeatGradeRule.Better,
|
||||
};
|
||||
// Parallelkurse (gleiche Arbeit, mehrere Kurse): beim Duplizieren verknüpfen, damit die
|
||||
// Klausuren-Hauptseite zwischen ihnen umschalten kann. Der Anker ist die Id der zuerst
|
||||
// angelegten Klausur der Kette — schon verknüpfte Quellen geben ihren Anker weiter.
|
||||
if (_duplicateSource is not null)
|
||||
Result.SharedExamGroupId = _duplicateSource.SharedExamGroupId ?? _duplicateSource.Id;
|
||||
_exams.Save(Result);
|
||||
if (_editingExam is not null && _results is not null)
|
||||
{
|
||||
var tasks = Result.Tasks.OrderBy(t => t.Nr).ToList();
|
||||
var existingResults = _results.GetByExam(Result.Id);
|
||||
foreach (var result in existingResults.Where(r => !r.Absent))
|
||||
{
|
||||
result.TotalPoints = ExamScoringService.Achieved(tasks, result.Points, result.Variant);
|
||||
result.Grade = _grading.CalculateGrade(result.TotalPoints,
|
||||
ExamScoringService.Maximum(tasks, result.Variant), gradingKey);
|
||||
}
|
||||
_results.SaveMany(existingResults);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +427,9 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
[ObservableProperty] private double _weight = 1.0;
|
||||
[ObservableProperty] private bool _isCompetencyPanelOpen;
|
||||
[ObservableProperty] private bool _showWeight;
|
||||
[ObservableProperty] private string _variantName = "Beide";
|
||||
[ObservableProperty] private bool _isBonus;
|
||||
public string[] VariantOptions => ["Beide", "A", "B"];
|
||||
|
||||
public List<CompetencyReference> Competencies { get; }
|
||||
public ObservableCollection<CompetencyTagGroup> CompetencyTagGroups { get; } = [];
|
||||
@@ -409,6 +478,8 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
[RelayCommand] private void MoveDown() => OnMoveDown?.Invoke(this);
|
||||
|
||||
partial void OnMaxPointsChanged(double value) => OnChanged?.Invoke();
|
||||
partial void OnVariantNameChanged(string value) => OnChanged?.Invoke();
|
||||
partial void OnIsBonusChanged(bool value) => OnChanged?.Invoke();
|
||||
|
||||
public ExamTask ToModel() => new()
|
||||
{
|
||||
@@ -416,6 +487,8 @@ public partial class ExamTaskEditItem : ObservableObject
|
||||
Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(),
|
||||
MaxPoints = MaxPoints,
|
||||
Weight = Weight,
|
||||
Variant = VariantName switch { "A" => ExamVariant.A, "B" => ExamVariant.B, _ => null },
|
||||
IsBonus = IsBonus,
|
||||
Competencies = Competencies,
|
||||
};
|
||||
}
|
||||
@@ -178,6 +178,7 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
|
||||
var cells = new List<string>();
|
||||
var numeric = new List<(string Grade, double Weight)>();
|
||||
var examEntries = new List<(Exam Exam, string Grade)>();
|
||||
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
@@ -189,8 +190,10 @@ public partial class GradeOverviewTabViewModel : ObservableObject
|
||||
if (result.Absent) { cells.Add("abwesend"); continue; }
|
||||
var display = FormatValue(result.Grade);
|
||||
cells.Add(display);
|
||||
if (result.Grade is not null) numeric.Add((result.Grade, 1.0));
|
||||
if (result.Grade is not null) examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
numeric.AddRange(ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)));
|
||||
|
||||
foreach (var key in gradeColumnKeys)
|
||||
{
|
||||
|
||||
@@ -206,11 +206,15 @@ public partial class GroupOverviewViewModel : ObservableObject
|
||||
|
||||
private (double Average, int Count)? ComputeExamAverage(Guid groupId)
|
||||
{
|
||||
var system = _groups.GetById(groupId)?.GradingSystem ?? GradingSystem.Grades1To6;
|
||||
var grades = _exams.GetByGroup(groupId)
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id))
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => (Grade: r.Grade!, Weight: 1.0))
|
||||
.ToList();
|
||||
.SelectMany(e => _examResults.GetByExam(e.Id)
|
||||
.Where(r => !r.Absent && !string.IsNullOrWhiteSpace(r.Grade))
|
||||
.Select(r => (r.StudentId, Exam: e, Grade: r.Grade!)))
|
||||
.GroupBy(x => x.StudentId)
|
||||
.SelectMany(g => ExamScoringService.EffectiveGrades(
|
||||
g.Select(x => (x.Exam, x.Grade)), system))
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
return grades.Count == 0 ? null : (_grading.WeightedAverage(grades), grades.Count);
|
||||
}
|
||||
|
||||
|
||||
@@ -427,6 +427,11 @@ public partial class GroupDetailViewModel : ObservableObject
|
||||
if (SelectedExam is null || OnConfirmDeleteExam is null) return;
|
||||
var selected = SelectedExam;
|
||||
if (!await OnConfirmDeleteExam(selected)) return;
|
||||
foreach (var repeat in _exams.GetByGroup(Group!.Id).Where(e => e.RepeatOfExamId == selected.Id))
|
||||
{
|
||||
repeat.RepeatOfExamId = null;
|
||||
_exams.Save(repeat);
|
||||
}
|
||||
_exams.Delete(selected.Id);
|
||||
SelectedExam = null;
|
||||
ReloadExams();
|
||||
@@ -618,12 +623,15 @@ public class ExamSummary
|
||||
public string StatusColorHex { get; }
|
||||
public string ReturnedAtDisplay { get; }
|
||||
public string NiveauLabel { get; }
|
||||
public string TypeLabel { get; }
|
||||
|
||||
public ExamSummary(Core.Models.Exam e)
|
||||
{
|
||||
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy"); Status = e.Status;
|
||||
ReturnedAtDisplay = e.ReturnedAt?.ToString("dd.MM.yyyy") ?? "";
|
||||
NiveauLabel = e.Niveau.HasValue ? NiveauDisplay.ToName(e.Niveau) : "alle";
|
||||
TypeLabel = e.RepeatOfExamId is not null ? (e.Tasks.Any(t => t.Variant is not null) ? "Wdh. · A/B" : "Wiederholung")
|
||||
: e.Tasks.Any(t => t.Variant is not null) ? "A/B" : "Klausur";
|
||||
StatusLabel = e.Status switch
|
||||
{
|
||||
ExamStatus.Planned => "Geplant",
|
||||
|
||||
@@ -132,15 +132,17 @@ public partial class ReportGradeDialogViewModel : ObservableObject
|
||||
continue;
|
||||
}
|
||||
|
||||
var examGrades = new List<(string Grade, double Weight)>();
|
||||
var examEntries = new List<(Exam Exam, string Grade)>();
|
||||
foreach (var exam in exams)
|
||||
{
|
||||
if (membership is not null && !GroupMembershipService.IsActiveOn(membership, exam.Date)) continue;
|
||||
if (exam.Niveau.HasValue && membership?.Niveau != exam.Niveau) continue;
|
||||
if (!resultsByExam[exam.Id].TryGetValue(student.Id, out var result)) continue;
|
||||
if (result.Absent || result.Grade is null) continue;
|
||||
examGrades.Add((result.Grade, 1.0));
|
||||
examEntries.Add((exam, result.Grade));
|
||||
}
|
||||
var examGrades = ExamScoringService.EffectiveGrades(examEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
|
||||
var participationGrades = allGrades
|
||||
.Where(g => g.StudentId == student.Id && g.Category == GradeCategory.Participation
|
||||
|
||||
@@ -143,6 +143,7 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
ExamRows.Clear();
|
||||
ExamHistory.Points.Clear();
|
||||
_examGradesRaw = [];
|
||||
var effectiveExamEntries = new List<(Exam Exam, string Grade)>();
|
||||
int? previousNoteEquivalent = null;
|
||||
foreach (var exam in _exams.GetByGroup(_groupId)
|
||||
.Where(e => e.Date >= periodFrom && e.Date <= periodTo)
|
||||
@@ -153,13 +154,15 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
var allResults = _results.GetByExam(exam.Id);
|
||||
var own = allResults.FirstOrDefault(r => r.StudentId == _studentId);
|
||||
string? ownGrade = own is { Absent: false, Grade: not null } ? own.Grade : null;
|
||||
if (ownGrade is not null) _examGradesRaw.Add((ownGrade, 1.0));
|
||||
if (ownGrade is not null) effectiveExamEntries.Add((exam, ownGrade));
|
||||
|
||||
var classGrades = allResults.Where(r => !r.Absent && r.Grade is not null)
|
||||
.Select(r => (r.Grade!, 1.0)).ToList();
|
||||
var classAverage = classGrades.Count > 0 ? _grading.WeightedAverage(classGrades) : (double?)null;
|
||||
|
||||
ExamRows.Add(new StudentExamRow(exam.Title, exam.Date, ownGrade,
|
||||
var examLabel = exam.Title + (exam.Tasks.Any(t => t.Variant is not null) && own is not null
|
||||
? $" (Fassung {own.Variant})" : "");
|
||||
ExamRows.Add(new StudentExamRow(examLabel, exam.Date, ownGrade,
|
||||
classAverage.HasValue ? FormatGrade(classAverage.Value) : "–"));
|
||||
|
||||
// Gleiche Balken-Sparkline wie die bestehende Notenentwicklung (2.5): Rohwert auf die
|
||||
@@ -174,10 +177,12 @@ public partial class StudentPerformanceOverviewViewModel : ObservableObject
|
||||
var warnings = new List<string>();
|
||||
if (isDrop) warnings.Add("Abfall um ≥ 1 Note");
|
||||
if (isFailing) warnings.Add("Versetzungsgefährdung");
|
||||
ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, exam.Title, ownGrade ?? "–",
|
||||
ExamHistory.Points.Add(new GradeHistoryPoint(exam.Date, examLabel, ownGrade ?? "–",
|
||||
noteEquivalent, warnings.Count > 0, string.Join(" · ", warnings)));
|
||||
if (noteEquivalent.HasValue) previousNoteEquivalent = noteEquivalent;
|
||||
}
|
||||
_examGradesRaw = ExamScoringService.EffectiveGrades(effectiveExamEntries, _gradingSystem)
|
||||
.Select(grade => (Grade: grade, Weight: 1.0)).ToList();
|
||||
_examsAverage = _examGradesRaw.Count > 0 ? _grading.WeightedAverage(_examGradesRaw) : null;
|
||||
|
||||
// ── Mitarbeit & Sonstige ──────────────────────────────────────────
|
||||
|
||||
@@ -54,7 +54,8 @@ public partial class ExamsOverviewView : UserControl
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, group.GradingSystem);
|
||||
exam, group.GradingSystem,
|
||||
App.Services.GetRequiredService<IStudentRepository>());
|
||||
|
||||
var dialog = new ExamEvaluationDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -52,6 +52,29 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Wiederholung einer Arbeit (optional)" FontSize="12" Opacity="0.7"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<ComboBox ItemsSource="{Binding AvailableOriginalExams}" SelectedItem="{Binding SelectedOriginalExam}"
|
||||
PlaceholderText="Keine Wiederholung" HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:Exam">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Date}"/>
|
||||
<TextBlock Text="{Binding Title}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<Button Grid.Column="1" Content="Auswahl löschen" Command="{Binding ClearOriginalExamCommand}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding RepeatRuleOptions}" SelectedItem="{Binding RepeatRuleName}"
|
||||
IsVisible="{Binding HasSelectedOriginalExam}"/>
|
||||
<TextBlock Text="{Binding RepeatError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding RepeatError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Notizen" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding Notes}" AcceptsReturn="True" Height="56" TextWrapping="Wrap"/>
|
||||
@@ -72,6 +95,8 @@
|
||||
<TextBlock Grid.Column="1" Text="Achtung: noch keine Punkte vergeben" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding TotalPointsWarning}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding TaskPointsError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding TaskPointsError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Tasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -95,6 +120,15 @@
|
||||
ToolTip.Tip="Entfernen" Margin="4,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<TextBlock Text="Fassung" VerticalAlignment="Center" FontSize="11"/>
|
||||
<ComboBox ItemsSource="{Binding VariantOptions}" SelectedItem="{Binding VariantName}" Width="90"/>
|
||||
</StackPanel>
|
||||
<CheckBox Content="Bonusaufgabe" IsChecked="{Binding IsBonus}"
|
||||
ToolTip.Tip="Erreichte Bonuspunkte erhöhen die Punktzahl; die Maximalpunkte zählen nicht zum Grundwert."/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Content="{Binding CompetencySummary}" Command="{Binding ToggleCompetencyPanelCommand}"
|
||||
HorizontalAlignment="Left" FontSize="11" Padding="6,2"/>
|
||||
|
||||
|
||||
@@ -111,6 +111,31 @@
|
||||
|
||||
<Separator/>
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasRepeatComparison}">
|
||||
<TextBlock Text="Vergleich mit der Wiederholung" FontSize="14" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding RepeatComparisonTitle}" FontSize="11" Opacity="0.65"/>
|
||||
<Grid ColumnDefinitions="2*,*,*,*">
|
||||
<TextBlock Text="Schüler" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="1" Text="Erste" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="2" Text="Wiederholung" FontWeight="Bold" FontSize="11"/>
|
||||
<TextBlock Grid.Column="3" Text="Zählt" FontWeight="Bold" FontSize="11"/>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding RepeatComparison}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RepeatComparisonItem">
|
||||
<Grid ColumnDefinitions="2*,*,*,*" Margin="0,2">
|
||||
<TextBlock Text="{Binding StudentName}"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding OriginalGrade}"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding RepeatGrade}"/>
|
||||
<TextBlock Grid.Column="3" Text="{Binding EffectiveGrade}" FontWeight="Bold"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<Separator IsVisible="{Binding HasRepeatComparison}"/>
|
||||
|
||||
<!-- Notenschlüssel verschieben -->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="Notenschlüssel verschieben" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||
|
||||
@@ -41,12 +41,27 @@ public partial class ExamGradingDialog : Window
|
||||
Width = new DataGridLength(170, DataGridLengthUnitType.Pixel),
|
||||
});
|
||||
|
||||
if (vm.HasVariants)
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = "Fassung", Width = new DataGridLength(82, DataGridLengthUnitType.Pixel),
|
||||
CellTemplate = new FuncDataTemplate<ExamResultRow>((row, _) =>
|
||||
{
|
||||
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";
|
||||
return choice;
|
||||
}),
|
||||
});
|
||||
|
||||
for (var i = 0; i < vm.Tasks.Count; i++)
|
||||
{
|
||||
var task = vm.Tasks[i];
|
||||
grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}",
|
||||
Header = (string.IsNullOrWhiteSpace(task.Title) ? $"Aufg. {task.Nr}" : $"{task.Nr}. {task.Title}")
|
||||
+ (task.Variant is null ? "" : $" ({task.Variant})")
|
||||
+ (task.IsBonus ? " ★ Bonus" : ""),
|
||||
Width = new DataGridLength(1, DataGridLengthUnitType.Star),
|
||||
CellTemplate = BuildPointsCellTemplate(i, grid, vm.Tasks.Count),
|
||||
});
|
||||
@@ -91,6 +106,7 @@ public partial class ExamGradingDialog : Window
|
||||
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);
|
||||
@@ -99,6 +115,8 @@ public partial class ExamGradingDialog : Window
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Datum" Binding="{Binding Date}" Width="110"/>
|
||||
<DataGridTextColumn Header="Titel" Binding="{Binding Title}" Width="*"/>
|
||||
<DataGridTextColumn Header="Art" Binding="{Binding TypeLabel}" Width="105"/>
|
||||
<DataGridTextColumn Header="Niveau" Binding="{Binding NiveauLabel}" Width="80"
|
||||
IsVisible="{Binding IsDifferentiated}"/>
|
||||
<DataGridTemplateColumn Header="Status" Width="150">
|
||||
|
||||
@@ -255,7 +255,8 @@ public partial class GroupDetailView : UserControl
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
groupId, vm.Group.SubjectId, vm.Group.GradeLevel, vm.Group.GradingSystem,
|
||||
vm.SubjectName, vm.Group.IsDifferentiated, editingExam, duplicateSource,
|
||||
competencyCatalogVersionId);
|
||||
competencyCatalogVersionId,
|
||||
App.Services.GetRequiredService<IExamResultRepository>());
|
||||
|
||||
var dialog = new ExamDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
@@ -295,7 +296,8 @@ public partial class GroupDetailView : UserControl
|
||||
App.Services.GetRequiredService<IExamRepository>(),
|
||||
App.Services.GetRequiredService<IExamResultRepository>(),
|
||||
App.Services.GetRequiredService<GradingService>(),
|
||||
exam, vm.Group.GradingSystem);
|
||||
exam, vm.Group.GradingSystem,
|
||||
App.Services.GetRequiredService<IStudentRepository>());
|
||||
|
||||
var dialog = new ExamEvaluationDialog { DataContext = dialogVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
<Style Selector="Border.timetablecell.assigned">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
</Style>
|
||||
<Style Selector="Button.examWeekCell">
|
||||
<Setter Property="BorderBrush" Value="#FFCA28"/>
|
||||
<Setter Property="BorderThickness" Value="3"/>
|
||||
</Style>
|
||||
<Style Selector="Button.beforeExamWeekCell">
|
||||
<Setter Property="BorderBrush" Value="#FFF3CD"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="Border.weekheader.today">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
|
||||
</Style>
|
||||
@@ -254,12 +262,21 @@
|
||||
<Border Classes="timetablecell" Classes.assigned="{Binding IsAssigned}"
|
||||
CornerRadius="6" IsVisible="{Binding IsSlotCell}">
|
||||
<Panel IsVisible="{Binding IsAssigned}">
|
||||
<Button Background="{Binding ColorHex}"
|
||||
<Button Background="{Binding ColorHex}" Classes.examWeekCell="{Binding HasExam}"
|
||||
Classes.beforeExamWeekCell="{Binding IsLastBeforeExam}"
|
||||
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch" CornerRadius="6" Padding="6"
|
||||
Command="{Binding $parent[ItemsControl;1].((vm:TimetableViewModel)DataContext).OpenWeekCellCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Spacing="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3" Margin="0,0,20,3">
|
||||
<Border Background="#FFCA28" CornerRadius="4" Padding="4,1" IsVisible="{Binding HasExam}">
|
||||
<TextBlock Text="KLAUSUR" FontSize="9" FontWeight="Bold" Foreground="#212121"/>
|
||||
</Border>
|
||||
<Border Background="#FFF3CD" CornerRadius="4" Padding="4,1" IsVisible="{Binding IsLastBeforeExam}">
|
||||
<TextBlock Text="VOR KLAUSUR" FontSize="9" FontWeight="Bold" Foreground="#5D4037"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock FontSize="11" FontWeight="Bold" Foreground="White" TextWrapping="Wrap">
|
||||
<Run Text="{Binding SubjectLabel}"/><Run Text=" · "/><Run Text="{Binding GroupName}"/>
|
||||
</TextBlock>
|
||||
@@ -278,8 +295,6 @@
|
||||
<Border Background="#B71C1C" CornerRadius="7" Padding="4,0" IsVisible="{Binding HasHolidayBadge}">
|
||||
<TextBlock Text="{Binding HolidayBadge}" FontSize="9" FontWeight="Bold" Foreground="White"/>
|
||||
</Border>
|
||||
<TextBlock Text="📝" FontSize="11" IsVisible="{Binding HasExam}" ToolTip.Tip="Klausur"/>
|
||||
<TextBlock Text="⏰" FontSize="11" IsVisible="{Binding IsLastBeforeExam}" ToolTip.Tip="Letzte Stunde vor der Klausur"/>
|
||||
<TextBlock Text="🧪" FontSize="11" IsVisible="{Binding HasExperiment}" ToolTip.Tip="Experiment geplant"/>
|
||||
<TextBlock Text="📓" FontSize="11" IsVisible="{Binding HasUnhandledHomework}"
|
||||
ToolTip.Tip="Hausaufgabe aus letzter Stunde noch nicht kontrolliert"/>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
public class ExamScoringServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BonusUndFassungen_VerwendenJeweilsDenRichtigenGrundwert()
|
||||
{
|
||||
var tasks = new List<ExamTask>
|
||||
{
|
||||
new() { MaxPoints = 10 },
|
||||
new() { MaxPoints = 5, Variant = ExamVariant.A },
|
||||
new() { MaxPoints = 8, Variant = ExamVariant.B },
|
||||
new() { MaxPoints = 2, IsBonus = true },
|
||||
};
|
||||
|
||||
Assert.Equal(15, ExamScoringService.Maximum(tasks, ExamVariant.A));
|
||||
Assert.Equal(18, ExamScoringService.Maximum(tasks, ExamVariant.B));
|
||||
Assert.Equal(17, ExamScoringService.Achieved(tasks, [10, 5, 8, 2], ExamVariant.A));
|
||||
Assert.Equal(20, ExamScoringService.Achieved(tasks, [10, 5, 8, 2], ExamVariant.B));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RepeatGradeRule.Better, GradingSystem.Grades1To6, "2", "4", "2")]
|
||||
[InlineData(RepeatGradeRule.Better, GradingSystem.Points0To15, "8", "11", "11")]
|
||||
[InlineData(RepeatGradeRule.Repeat, GradingSystem.Grades1To6, "2", "4", "4")]
|
||||
[InlineData(RepeatGradeRule.Original, GradingSystem.Grades1To6, "2", "4", "2")]
|
||||
public void Wiederholung_ZaehltGenauEinmalNachRegel(RepeatGradeRule rule,
|
||||
GradingSystem system, string first, string second, string expected)
|
||||
{
|
||||
var original = new Exam();
|
||||
var repeat = new Exam { RepeatOfExamId = original.Id, RepeatGradeRule = rule };
|
||||
var other = new Exam();
|
||||
|
||||
var grades = ExamScoringService.EffectiveGrades(
|
||||
[(original, first), (repeat, second), (other, "3")], system);
|
||||
|
||||
Assert.Equal(2, grades.Count);
|
||||
Assert.Contains(expected, grades);
|
||||
Assert.Contains("3", grades);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wiederholung_BessereNoteBeruecksichtigtTendenzUndFehlendesErgebnis()
|
||||
{
|
||||
var original = new Exam();
|
||||
var repeat = new Exam { RepeatOfExamId = original.Id };
|
||||
|
||||
Assert.Equal(["2+"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "2"), (repeat, "2+")], GradingSystem.Grades1To6));
|
||||
Assert.Equal(["2"], ExamScoringService.EffectiveGrades(
|
||||
[(original, "2")], GradingSystem.Grades1To6));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ Diese Regeln gelten für jede Aufgabe unten und müssen nicht wiederholt werden.
|
||||
|
||||
## 1. Klausuren & Leistungsüberprüfungen
|
||||
|
||||
- [x] **Nachtrag 2026-09: Klausurvarianten, Bonus und Wiederholung.** Aufgaben können
|
||||
beiden Fassungen oder nur A/B zugeordnet werden; die Punkteeingabe wählt die
|
||||
Fassung je Schüler. Bonuspunkte erhöhen das Ergebnis ohne den Grundwert zu
|
||||
erhöhen. Eine Wiederholung verweist auf die erste Arbeit und speichert die
|
||||
Regel „bessere“, „zweite“ oder „erste“ Note. Die Auswertung zeigt beide Noten
|
||||
je Schüler, Durchschnitt und Zeugnisnote zählen die Paarung nur einmal.
|
||||
Im Wochenstundenplan stehen Klausur und letzte Stunde davor als Textbadges
|
||||
oben in der Kachel; Klausurstunden haben zusätzlich einen gelben Rand.
|
||||
|
||||
Modelle `Exam`, `ExamTask`, `GradingKeyEntry`, `ExamResult` existieren bereits in
|
||||
[Exam.cs](LehrerApp.Core/Models/Exam.cs), Repositories ebenfalls. Die komplette UI fehlt.
|
||||
|
||||
|
||||
@@ -98,6 +98,14 @@ Einige berechnete Werte bleiben absichtlich gespeichert:
|
||||
- `Exam.GradingKey` hält den für die konkrete Klausur verwendeten Notenschlüssel
|
||||
fest und ist unabhängig von später geänderten Vorlagen.
|
||||
- `Exam.Tasks` hält die Aufgabenstruktur der konkreten Klausur fest.
|
||||
- `ExamTask.Variant` ist bei gemeinsamen Aufgaben null, sonst `A` oder `B`.
|
||||
`ExamResult.Variant` wählt die Fassung je Schüler. `ExamTask.IsBonus` nimmt die
|
||||
Maximalpunkte aus dem Grundwert des Notenschlüssels; erreichte Bonuspunkte zählen
|
||||
weiterhin zum Ergebnis.
|
||||
- `Exam.RepeatOfExamId` verweist auf die ursprüngliche Arbeit derselben Gruppe.
|
||||
`RepeatGradeRule` legt fest, ob die bessere, die wiederholte oder die erste Note
|
||||
zählt. Beide Ergebnisse bleiben sichtbar, gehen aber als ein Wert in die
|
||||
berechneten Durchschnitts- und Zeugnisnoten ein.
|
||||
- `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`,
|
||||
`ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber
|
||||
zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen
|
||||
|
||||
Reference in new issue
Block a user