Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s

Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag,
sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter
(Standardansicht) sortiert die Klasse nach einem kumulierten
Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status,
und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein
(Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort
deutlich hoch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 21:36:34 +02:00
co-authored by Claude Sonnet 5
parent fb7a015b42
commit 987b49e761
4 changed files with 393 additions and 14 deletions
@@ -228,6 +228,198 @@ public sealed class ClassTeacherViewModelsTests
Assert.Null(row.YearSummaryTooltip);
}
// ── Fehlquote behandelt Verspätung nicht mehr wie einen ganzen Fehltag (Nutzer-Feedback) ────
[Fact]
public void RosterBuild_ReineVerspaetungZaehltNichtInDieFehlquote()
{
// Nutzer-Feedback: "10× 5 Min. verspätet" wurde bisher exakt wie "10× unentschuldigt
// gefehlt" behandelt. Reine (nicht unentschuldigte) Verspätung darf die Quote nicht mehr
// treiben, taucht aber weiterhin als eigene Zahl auf.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = Enumerable.Range(0, 3)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(0, row.YearAbsenceDayCount);
Assert.Equal(3, row.YearLateDayCount);
Assert.Equal(0, row.YearAbsenceRatePercent);
Assert.True(row.HasYearSummary);
Assert.Contains("verspätet", row.YearSummaryLabel);
Assert.DoesNotContain("%", row.YearSummaryLabel);
Assert.Contains("3 Tage verspätet", row.YearSummaryTooltip);
Assert.Contains("zählt nicht zur Quote", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_UnentschuldigtUndVerspaetetZaehltAlsUnentschuldigtNichtAlsVerspaetung()
{
// Wer unentschuldigt UND verspätet ist, bleibt der schwerwiegendere Fall (dieselbe
// Rangfolge wie AttentionRank/StatusKind/BuildTrend: Unentschuldigt vor Verspätet) — zählt
// deshalb weiter voll in die Quote statt in YearLateDayCount zu verschwinden.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 0, 60,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.Equal(1, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(0, row.YearLateDayCount);
}
[Fact]
public void PatternScore_GewichtetUnentschuldigtDeutlichStaerkerAlsVerspaetung()
{
// Kernfall aus dem Nutzer-Feedback: 10× 5 Minuten verspätet darf nicht denselben Score
// ergeben wie 10× ganztägig unentschuldigt gefehlt.
var lateStudent = Student(1001, "Verpennt Timo");
var truantStudent = Student(1002, "Geschwaenzt Cem");
var yearAbsences = Enumerable.Range(0, 10)
.SelectMany(i => new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Verpennt Timo", 1001, 0, 5,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Geschwaenzt Cem", 1002, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
})
.ToArray();
var roster = ClassTeacherRosterRow.Build([lateStudent, truantStudent], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1));
var timo = Assert.Single(roster, r => r.StudentName == "Verpennt Timo");
var cem = Assert.Single(roster, r => r.StudentName == "Geschwaenzt Cem");
Assert.Equal(5.0, timo.PatternScore);
Assert.Equal(30.0, cem.PatternScore);
Assert.True(cem.PatternScore > timo.PatternScore * 5);
}
[Fact]
public void PatternScore_ErfasstMusterAuchWennHeuteAnwesend()
{
// Nutzer-Feedback: ein/e Schüler*in, die/der heute da ist, aber zuvor mehrfach unentschuldigt
// fehlte, soll nicht spurlos aus der Musterauswertung verschwinden — anders als bei der
// reinen "heute auffällig"-Sortierung (HasAbsenceToday).
var student = Student(1001, "Ada Müller");
var yearAbsences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10 + i), "Müller Ada", 1001, 6, 270,
["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true))
.ToArray();
// Kein todayAbsences-Eintrag: heute anwesend.
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1)));
Assert.False(row.HasAbsenceToday);
Assert.True(row.PatternScore > 0);
}
[Fact]
public void PatternScore_IstNullOhneJahresdaten()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(0.0, row.PatternScore);
}
// ── Klassenbucheinträge anderer Lehrkräfte fließen ebenfalls in den Score ein ────────────────
[Fact]
public void PatternScore_NegativeKlassenbucheintraegeErhoehenDenScoreAuchOhneFehlzeiten()
{
var student = Student(1001, "Ada Müller");
var yearRegister = Enumerable.Range(0, 4)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Müller Ada", "test",
"Unterrichtsstörung", "Negativ", "Gestört"))
.ToArray();
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister));
Assert.Equal(4, row.YearNegativeClassRegisterCount);
Assert.Equal(4.0, row.PatternScore);
Assert.True(row.HasYearSummary);
Assert.Contains("negative Klassenbucheinträge", row.YearSummaryLabel);
}
[Fact]
public void PatternScore_HausaufgabenUndMitarbeitSchluesselwoerterZaehlenStaerkerAlsPlainNegativ()
{
var withKeyword = Student(1001, "Keyword Kim");
var plain = Student(1002, "Plain Priya");
var yearRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Keyword Kim", "test",
"Hausaufgaben fehlen", "Negativ", "HA nicht dabei"),
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Plain Priya", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var roster = ClassTeacherRosterRow.Build([withKeyword, plain], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var kim = Assert.Single(roster, r => r.StudentName == "Keyword Kim");
var priya = Assert.Single(roster, r => r.StudentName == "Plain Priya");
Assert.True(kim.PatternScore > priya.PatternScore);
}
[Fact]
public void PatternScore_SuspendiertSchiesstDenScoreWeitUeberVieleAndereEintraegeHinaus()
{
var suspended = Student(1001, "Suspendiert Sam");
var chronic = Student(1002, "Viele Vera");
var yearRegister = new List<UntisForeignClassRegisterEventDto>
{
new("6a", 20260810, "Deu", "Suspendiert Sam", "test", "Ordnungsmaßnahme", "Negativ",
"Vom Unterricht suspendiert"),
};
yearRegister.AddRange(Enumerable.Range(0, 6)
.Select(i => new UntisForeignClassRegisterEventDto("6a", 20260810 + i, "Deu", "Viele Vera", "test",
"Sonstiges", "Negativ", "Gestört")));
var roster = ClassTeacherRosterRow.Build([suspended, chronic], [], [],
new DateOnly(2026, 8, 26), schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1),
yearClassRegisterEntries: yearRegister);
var sam = Assert.Single(roster, r => r.StudentName == "Suspendiert Sam");
var vera = Assert.Single(roster, r => r.StudentName == "Viele Vera");
Assert.True(sam.HasSuspensionEntry);
Assert.True(sam.PatternScore > vera.PatternScore);
Assert.Contains("Suspendierung vermerkt", sam.YearSummaryTooltip);
}
[Fact]
public void PatternScore_OhneEigeneJahresklassenbuchlisteFaelltAufDenUebergebenenParameterZurueck()
{
// Rückwärtskompatibilität: Aufrufer, die (wie die bestehenden Tests) nur den ursprünglichen
// recentClassRegisterEntries-Parameter befüllen, sollen trotzdem einen Score aus diesen
// Einträgen bekommen statt stillschweigend leer auszugehen.
var student = Student(1001, "Ada Müller");
var entries = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260810, "Deu", "Müller Ada", "test",
"Sonstiges", "Negativ", "Gestört"),
};
var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], entries,
new DateOnly(2026, 8, 26)));
Assert.True(row.PatternScore > 0);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{