Klausurvarianten, Bonuspunkte und Wiederholungen ergänzen

This commit is contained in:
admin committed 2026-09-26 10:42:11 +02:00
1 parent 54ad7f7419
commit 431d023c1a
22 files changed
+522 -40

No files matched your search

+9
View File
@@ -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));
}
}
+9
View File
@@ -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.
+8
View File
@@ -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