Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s

Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür
nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem
Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option.

Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und
zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach
Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt
jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 11:56:35 +02:00
co-authored by Claude Sonnet 5
parent 527c186090
commit cdbed69d8b
6 changed files with 304 additions and 21 deletions
@@ -264,6 +264,32 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
}
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
@@ -290,6 +316,87 @@ public sealed class ClassTeacherViewModelsTests
Assert.False(rows[1].IsOverdue);
}
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]