Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit
CI / build-and-test (push) Canceled after 0s

Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den
betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und
Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen
jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:24:16 +02:00
co-authored by Claude Sonnet 5
parent 1215d4ac22
commit a088823b30
5 changed files with 75 additions and 3 deletions
+17 -1
View File
@@ -77,13 +77,29 @@ public class GradingService
public double WeightedAverage(List<(string Grade, double Weight)> grades)
{
var numeric = grades
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
.Where(g => g.Value.HasValue).ToList();
if (numeric.Count == 0) return 0;
var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
}
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
private static double? ParseGradeValue(string grade)
{
var trimmed = grade.Trim();
if (trimmed.Length == 0) return null;
var tendency = 0.0;
var core = trimmed;
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
return int.TryParse(core, out var n) ? n + tendency : null;
}
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
@@ -20,6 +20,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
private readonly IExamRepository _exams;
private readonly IExamResultRepository _results;
private readonly IGradeRepository _grades;
private readonly IUnitRepository _units;
private readonly Dictionary<Guid, GroupMembership> _membershipsByStudent;
private readonly GradingService _grading;
private readonly Guid _groupId;
@@ -75,11 +76,11 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
IParticipationAspectRepository aspects, IParticipationSectionRepository sectionRepo,
IStudentRepository students, IGroupMembershipRepository memberships,
IExamRepository exams, IExamResultRepository results,
IGradeRepository grades, GradingService grading,
IGradeRepository grades, IUnitRepository units, GradingService grading,
Guid groupId, string schoolYear, GradingSystem gradingSystem, string groupLabel)
{
_sessions = sessions; _entries = entries; _sectionRepo = sectionRepo;
_exams = exams; _results = results; _grades = grades; _grading = grading;
_exams = exams; _results = results; _grades = grades; _units = units; _grading = grading;
_groupId = groupId; _schoolYear = schoolYear; _gradingSystem = gradingSystem;
GroupLabel = groupLabel;
_membershipsByStudent = memberships.GetByGroup(groupId).ToDictionary(m => m.StudentId);
@@ -98,6 +99,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
_rollupPeriod = RollupPeriodOptions[0];
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
if (_students.Count > 0) ShowStudent(0);
}
@@ -277,6 +279,23 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
_sectionList.Count > 0 ? _sectionList.Max(s => s.EndDate).AddDays(1)
: (_allSessions.Count > 0 ? _allSessions.Min(s => s.Date) : DateOnly.FromDateTime(DateTime.Today));
/// Schlägt als Abschnittsende bewusst nicht immer "heute" vor: Wenn seit Abschnittsbeginn
/// eine Unterrichtseinheit (4.2) abgeschlossen wurde, ist deren Ende meist die fachlich
/// sinnvollere Zäsur für eine Mitarbeit-Teilnote als das Bearbeitungsdatum im Wizard.
private DateOnly SuggestSectionEndDate()
{
var openStart = ComputeOpenStart();
var today = DateOnly.FromDateTime(DateTime.Today);
var completedUnitEnd = _units.GetByGroup(_groupId)
.Where(u => u.Status == UnitStatus.Completed && u.EndDate.HasValue)
.Select(u => u.EndDate!.Value)
.Where(d => d >= openStart && d <= today)
.OrderByDescending(d => d)
.Cast<DateOnly?>()
.FirstOrDefault();
return completedUnitEnd ?? today;
}
private void BuildSections(Guid studentId)
{
Sections.Clear();
@@ -378,6 +397,7 @@ public partial class ParticipationWizardDialogViewModel : ObservableObject
}
NewSectionLabel = $"Abschnitt {_sectionList.Count + 1}";
NewSectionEndDateText = SuggestSectionEndDate().ToString("dd.MM.yyyy");
BuildTimeline(CurrentStudentId);
BuildSections(CurrentStudentId);
}
@@ -350,6 +350,7 @@ public partial class ParticipationTabView : UserControl
App.Services.GetRequiredService<IExamRepository>(),
App.Services.GetRequiredService<IExamResultRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
App.Services.GetRequiredService<IUnitRepository>(),
App.Services.GetRequiredService<GradingService>(),
tabVm.GroupId, tabVm.SchoolYear, tabVm.GradingSystem, tabVm.GroupLabel);
+19
View File
@@ -181,6 +181,25 @@ public class GradingServiceTests
Assert.Equal(0, result);
}
[Theory]
[InlineData("3+", 2.6667)]
[InlineData("2-", 2.3333)]
[InlineData("1+", 0.6667)]
[InlineData("6-", 6.3333)]
public void WeightedAverage_NotenMitTendenz_VerschiebtUmEinDrittel(string grade, double expected)
{
var result = _sut.WeightedAverage([(grade, 1.0)]);
Assert.Equal(expected, result, precision: 4);
}
[Fact]
public void WeightedAverage_NotenMitTendenzGemischtMitGanzenNoten_GewichtetKorrekt()
{
// (3+ [2,667] + 2 [2,0]) / 2 = 2,333
var result = _sut.WeightedAverage([("3+", 1.0), ("2", 1.0)]);
Assert.Equal(2.3333, result, precision: 4);
}
// ── RoundToGrade ──────────────────────────────────────────────────────────
[Fact]
+16
View File
@@ -252,6 +252,15 @@ eigenes Werkzeug, nur im Kopf überschlagen.
**Bewusst zurückgestellt:** eine Übersicht über alle Fächer eines Schülers gleichzeitig
(Gesamt-Zeugnisvorschau) — unterschiedliche Notenschemata/Gewichtungen pro Kurs wären ein
eigener, größerer Umbau.
- [x] **2.6.5** Bugfix (Nutzer-Feedback): frei eingetragene Noten mit Tendenz ("3+", "2-") in den
editierbaren Mitarbeit-/Sonstige-Feldern (2.6.4) wurden von `GradingService.WeightedAverage`
nicht erkannt (`int.TryParse` schlägt bei einem Suffix fehl) — bestanden nur Tendenznoten in
einem Bereich, wurde er komplett auf 0 statt ausgelassen berechnet, was den Zielnoten-Rechner
und die Zeugnisnote verfälschte. Neue `GradingService.ParseGradeValue()` erkennt ein
abschließendes `+`/`-` und verschiebt um ein Drittel Richtung der besseren Note
(z.B. "3+" → 2,67, "2-" → 2,33, üblicher Drittelabstand deutscher Notensysteme) — betrifft
damit auch die "Gesamt"-Spalte der Notenübersicht (2.1.2) und `CalculateReportGrade`, die
beide auf `WeightedAverage` aufbauen.
---
@@ -442,6 +451,13 @@ zusammenziehen, daraus die Halbjahresnote bilden":
- Dashboard-Kachel "Offene Entschuldigungen": listet alle `ExcusePending`-Einträge der letzten
21 Tage gruppenübergreifend mit Direktauflösung; ältere Einträge werden ausgeblendet statt
automatisch entschieden (pädagogische Entscheidung bleibt bei der Lehrkraft).
- **Nachtrag (Nutzer-Feedback, nach erster produktiver Nutzung):** Vorgeschlagenes Abschnittsende
im Assistenten war immer "heute" (`NewSectionEndDateText`), obwohl die fachlich naheliegende
Zäsur oft schon feststeht, wenn die aktuelle Unterrichtseinheit (4.1) abgeschlossen ist. Neue
`SuggestSectionEndDate()` schlägt stattdessen das `Unit.EndDate` der zuletzt abgeschlossenen
Einheit (`UnitStatus.Completed`) vor, sofern deren Ende im laufenden, noch offenen Abschnitt
liegt (zwischen dem Ende des letzten Abschnitts und heute) — sonst bleibt es bei "heute". Der
Vorschlag ist nur eine Vorbelegung des Textfelds, weiterhin frei überschreibbar.
---