using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.ClassTeacher; using Xunit; namespace LehrerApp.Desktop.Tests; public sealed class ClassTeacherViewModelsTests { [Fact] public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus() { var month = new DateOnly(2026, 9, 1); var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 2), "Müller Ada", 1, 0, 12, ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Schmidt Ben", 2, 2, 90, ["Mathe"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), }; var register = new[] { new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 3), "Deu", "Schmidt Ben", "test", "Störung", "Negativ", "Unterricht massiv gestört"), new ClassTeacherClassRegisterRow(new DateOnly(2026, 9, 4), "Mathe", "Müller Ada", "test", "Hausaufgaben", "Negativ", "Hausaufgaben fehlen"), }; var days = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: false); Assert.Equal(0, days.Count % 5); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "V"); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U"); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!"); Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H"); var withHomework = ClassTeacherDetailsViewModel.BuildCalendarDays(month, absences, register, includeHomework: true); Assert.Contains(withHomework.SelectMany(d => d.Events), e => e.Code == "H"); } [Fact] public void Kompaktkalender_ZeigtNurMonatstageUndJeTagDasStaerksteSignal() { var month = new DateOnly(2026, 9, 1); var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 3), "Müller Ada", 1, 2, 90, ["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 4), "Schmidt Ben", 2, 2, 90, ["Mathe"], [1, 2], ["entsch."], ["Absent"], null, null, false), }; var register = new[] { new UntisForeignClassRegisterEventDto("6a", 20260903, "Deu", "Müller Ada", "test", "Störung", "Negativ", "Unterricht gestört"), new UntisForeignClassRegisterEventDto("6a", 20260905, "Mathe", "Schmidt Ben", "test", "Hausaufgaben", "Negativ", "Hausaufgaben fehlen"), }; var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, register, new DateOnly(2026, 9, 3)); Assert.Equal(30, days.Count); Assert.False(days[0].HasSignal); Assert.Equal("1", days[0].DayNumber); Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag Assert.True(days[2].IsToday); Assert.Contains("Ada Müller", days[2].Tooltip); Assert.Equal("E", days[3].SignalCode); Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet } [Fact] public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen() { var entries = new[] { new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent", "Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false), new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null, "Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true), new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu", "Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false), }; var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries); Assert.Equal(2, rows.Count); var max = rows[1]; Assert.Equal("Muster Max", max.StudentName); Assert.Equal(2, max.TotalAbsentPeriods); Assert.Equal(90, max.TotalAbsentMinutes); Assert.Equal(["Che", "Kunst"], max.Subjects); Assert.Equal([3, 5], max.PeriodNumbers.Order()); Assert.Equal(["nicht entsch."], max.Statuses); Assert.Equal(["Absent"], max.AbsenceReasons); Assert.Equal("Krank", max.Note); Assert.Null(max.ExcuseNote); Assert.True(max.CountsAsFullDay); var andere = rows[0]; Assert.Equal("Andere Schuelerin", andere.StudentName); Assert.Equal(1, andere.TotalAbsentPeriods); Assert.Equal(20, andere.TotalAbsentMinutes); Assert.Equal(["Deu"], andere.Subjects); Assert.Null(andere.Note); Assert.Equal("Zug verpasst", andere.ExcuseNote); Assert.False(andere.CountsAsFullDay); } [Fact] public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen() { var entries = new[] { new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null, null, 1, "24.08.26", true, null, 3, "nicht entsch.", false), new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null, null, 2, "25.08.26", true, null, 3, "nicht entsch.", false), }; var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries); Assert.Equal(2, rows.Count); Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date); Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date); } [Fact] public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu() { var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt"), Student(1003, "Cem Yilmaz"), }; var todayAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90, ["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false), }; var recentClassRegister = new[] { new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller", "Vergessen", "Organisation", "Buch vergessen"), }; var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister); Assert.Equal(3, roster.Count); var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller"); Assert.True(ada.HasAbsenceToday); Assert.Contains("nicht entsch.", ada.AbsenceTooltip); Assert.False(ada.HasRecentClassRegisterEntry); var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt"); Assert.False(ben.HasAbsenceToday); Assert.True(ben.HasRecentClassRegisterEntry); var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz"); Assert.False(cem.HasAbsenceToday); Assert.False(cem.HasRecentClassRegisterEntry); } [Fact] public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge() { // WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/ // Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich // muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug). var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") }; var todayAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45, ["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false), }; var recentClassRegister = new[] { new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller", "Vergessen", "Organisation", "Buch vergessen"), }; var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister); var ben = Assert.Single(roster); Assert.True(ben.HasAbsenceToday); Assert.True(ben.HasRecentClassRegisterEntry); } [Fact] public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden() { var students = new[] { Student(1001, "Ada Müller") }; var todayAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20, ["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false), }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26))); Assert.True(row.IsLate); Assert.True(row.IsUnexcused); Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText); Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind); } [Fact] public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern() { var students = new[] { Student(1001, "Zora Unauffällig"), Student(1002, "Ada Fehlzeit"), Student(1003, "Ben Klassenbuch"), }; var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90, ["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), }; var register = new[] { new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test", "Fehlende HA", "Negativ", "Hausaufgaben fehlen"), }; var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26)); Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"], rows.Select(r => r.StudentName)); } [Fact] public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn() { var students = new[] { Student(1001, "Ada Müller") }; var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90, ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270, ["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1))); Assert.True(row.HasYearSummary); Assert.Equal(2, row.YearAbsenceDayCount); Assert.Equal(1, row.YearUnexcusedDayCount); Assert.Equal(10, row.YearAbsenceRatePercent); Assert.Contains("10 %", row.YearSummaryLabel); Assert.Contains("2 von 20", row.YearSummaryTooltip); Assert.Contains("seit 01.08.", row.YearSummaryTooltip); Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip); } [Fact] public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip() { // Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste // direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte. var students = new[] { Student(1001, "Ada Müller") }; var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90, ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false), }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7, termStart: new DateOnly(2026, 8, 1))); Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip); } [Fact] public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote() { var students = new[] { Student(1001, "Ada Müller") }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26))); Assert.False(row.HasYearSummary); Assert.Equal("", row.YearSummaryLabel); 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 { 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() { var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") }; var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90, ["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false), }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 10)); Assert.Equal(1, row.YearAbsenceDayCount); } [Fact] public void TrendDay_AnteileSummierenSichZurGesamtquote() { var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d); Assert.Equal(1.0d, day.TotalFraction, precision: 10); } [Fact] public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran() { var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") }; var todayAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90, ["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90, ["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false), }; var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26)); var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller"); Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind); Assert.StartsWith("✕ ", ada.StatusTextWithGlyph); var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt"); Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind); Assert.StartsWith("△ ", ben.StatusTextWithGlyph); } [Fact] public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol() { var students = new[] { Student(1001, "Zora Unauffällig") }; var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26))); 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); } [Fact] public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse() { // Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen // Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August- // Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei // Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den // Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse // nötig statt CountSchoolWeekdays allein. var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45, ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), }; var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1)); Assert.Equal(new DateOnly(2026, 8, 13), termStart); } [Fact] public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck() { var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1)); Assert.Equal(new DateOnly(2026, 8, 1), termStart); } [Fact] public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein() { // Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit // Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im // Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus // EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf // die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21). var termStart = new DateOnly(2026, 8, 13); var noHolidaysInRange = Array.Empty(); var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays( termStart, new DateOnly(2026, 8, 31), noHolidaysInRange); Assert.Equal(13, schoolDays); } // ── Offene Entschuldigungen (Feature-Idee 2) ───────────────────────────── [Fact] public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte() { var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90, ["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45, ["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), }; var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26)); // StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname", // hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow). Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName)); Assert.Equal(6, rows[0].DaysOpen); Assert.True(rows[0].IsOverdue); Assert.Equal(1, rows[1].DaysOpen); 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 { [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 { [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 { [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] public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler() { var entries = new[] { new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test", "Hausaufgaben fehlen", "Negativ", "HA fehlt"), new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test", "Hausaufgaben fehlen", "Negativ", "HA fehlt"), new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test", "Hausaufgaben fehlen", "Negativ", "HA fehlt"), new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test", "Lob", "Positiv", "Gute Mitarbeit"), }; var rows = ClassTeacherCategoryAggregateRow.Build(entries); Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName)); var homework = rows[0]; Assert.Equal(3, homework.Count); Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel); Assert.Contains("1× Ada Müller", homework.TopStudentsLabel); } // ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ────── [Fact] public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag() { // 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage. var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), }; var names = new Dictionary { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" }; var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( absences, names, new HashSet()); var notice = Assert.Single(notices); Assert.Equal("Mona Muster", notice.StudentName); Assert.Contains("Montag", notice.Message); Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind); } [Fact] public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe() { var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), }; var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( absences, new Dictionary(), new HashSet()); Assert.Empty(notices); } [Fact] public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus() { var absences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45, ["Deu"], [1], ["entsch."], ["Absent"], null, null, false), }; var names = new Dictionary { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" }; var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns( absences, names, new HashSet { "Mona Muster" }); Assert.Empty(notices); } // ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ───────────── [Fact] public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName() { // Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde // UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent // muss deshalb FirstName/LastName getrennt verwenden, nicht FullName. var students = new List { new() { FirstName = "Ada", LastName = "Müller" }, new() { FirstName = "Ben", LastName = "Schmidt" }, }; var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students); Assert.NotNull(match); Assert.Equal("Ada", match!.FirstName); } [Fact] public void MatchStudent_LiefertNullOhneTreffer() { var students = new List { new() { FirstName = "Ada", LastName = "Müller" } }; Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students)); } private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) => new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName }; // ── Klassenbuch-Tab als Dokumentations-Hub: eigene Dokumentation neben WebUntis ───────────── [Fact] public void FilterOwnDocumentation_ZeigtNurEintraegeZugeordneterSchuelerImZeitraum() { var ada = Guid.NewGuid(); var fremd = Guid.NewGuid(); var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller") }; var docs = new List { new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Im Zeitraum" }, new() { StudentId = ada, Date = new DateOnly(2026, 7, 1), Title = "Vor dem Zeitraum" }, new() { StudentId = fremd, Date = new DateOnly(2026, 8, 20), Title = "Anderer Schüler" }, new() { StudentId = ada, Date = new DateOnly(2026, 8, 21), Title = "Gelöscht", IsDeleted = true }, }; var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), ""); Assert.Equal(["Im Zeitraum"], result.Select(d => d.Title)); } [Fact] public void FilterOwnDocumentation_SortiertEntwuerfeVorNeuestenZuerst() { var id = Guid.NewGuid(); var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (id, "Ada Müller") }; var docs = new List { new() { StudentId = id, Date = new DateOnly(2026, 8, 25), Title = "Neu, fertig" }, new() { StudentId = id, Date = new DateOnly(2026, 8, 10), Title = "Alt, Entwurf", IsDraft = true }, new() { StudentId = id, Date = new DateOnly(2026, 8, 20), Title = "Mittel, fertig" }, }; var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), ""); Assert.Equal(["Alt, Entwurf", "Neu, fertig", "Mittel, fertig"], result.Select(d => d.Title)); } [Fact] public void FilterOwnDocumentation_SchuelerfilterErkenntVertauschteReihenfolge() { // Derselbe Namensabgleich wie bei den WebUntis-Klassenbuchzeilen (siehe // RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge) - der Filter kommt aus der // Übersicht als "Nachname Vorname" oder "Vorname Nachname" je nach Bericht. var ada = Guid.NewGuid(); var ben = Guid.NewGuid(); var rosterMatches = new List<(Guid StudentId, string DisplayName)> { (ada, "Ada Müller"), (ben, "Ben Schmidt"), }; var docs = new List { new() { StudentId = ada, Date = new DateOnly(2026, 8, 20), Title = "Ada" }, new() { StudentId = ben, Date = new DateOnly(2026, 8, 20), Title = "Ben" }, }; var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation(docs, rosterMatches, new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "Müller Ada"); Assert.Equal(["Ada"], result.Select(d => d.Title)); } // ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ─────────────────────────── [Fact] public void BuildClassRegisterSnapshot_KopiertAlleFelderDerZeile() { var row = new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "mueller", "Fehlende HA", "Negativ", "Buch vergessen"); var snapshot = ClassTeacherDetailsViewModel.BuildClassRegisterSnapshot(row); Assert.Equal(new DateOnly(2026, 8, 20), snapshot.Date); Assert.Equal("Schmidt Ben", snapshot.StudentName); Assert.Equal("Deu", snapshot.Subject); Assert.Equal("mueller", snapshot.TeacherUsername); Assert.Equal("Fehlende HA", snapshot.CategoryName); Assert.Equal("Negativ", snapshot.CategoryGroup); Assert.Equal("Buch vergessen", snapshot.Text); } [Fact] public void FilterAvailableDocumentation_ZeigtNurUnverknuepfteDokumentationDerVorgangsSchueler() { var ada = Guid.NewGuid(); var ben = Guid.NewGuid(); var fremd = Guid.NewGuid(); var linked = new Documentation { StudentId = ada, Title = "Schon verknüpft" }; var unlinked = new Documentation { StudentId = ada, Title = "Noch offen" }; var otherStudentInCase = new Documentation { StudentId = ben, Title = "Ben, offen" }; var unrelated = new Documentation { StudentId = fremd, Title = "Anderer Schüler" }; var vorgang = new Vorgang { StudentIds = [ada, ben], DocumentationIds = [linked.Id] }; var allDocs = new List { linked, unlinked, otherStudentInCase, unrelated }; var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation(allDocs, vorgang); Assert.Equal(["Noch offen", "Ben, offen"], result.Select(d => d.Title)); } [Fact] public void FilterAvailableDocumentation_LeerOhneUnverknuepfteEintraege() { var ada = Guid.NewGuid(); var doc = new Documentation { StudentId = ada, Title = "Verknüpft" }; var vorgang = new Vorgang { StudentIds = [ada], DocumentationIds = [doc.Id] }; var result = ClassTeacherCasesViewModel.FilterAvailableDocumentation([doc], vorgang); Assert.Empty(result); } // ── Konfigurierbare Gewichtung + Dämpfung/Rückfall-Warnung nach geschlossenem Vorgang ───────── [Fact] public void MatchDomains_ErkenntBereicheAnSchluesselwoertern() { Assert.Equal([VorgangScoreDomain.Attendance], ClassTeacherRosterRow.MatchDomains(["Absentismus"])); Assert.Equal([VorgangScoreDomain.Lateness], ClassTeacherRosterRow.MatchDomains(["Verspätungen"])); Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Konflikte"])); Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Mitarbeit"])); Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Hausaufgaben"])); Assert.Equal([VorgangScoreDomain.Classbook], ClassTeacherRosterRow.MatchDomains(["Eskalation"])); } [Fact] public void MatchDomains_OhneErkanntesSchlagwortDaemptAlleBereiche() { // Nutzer-Entscheidung: ein Vorgang darf beim Schließen nie folgenlos bleiben, auch wenn sein // Schlagwort (z.B. "Elternkontakt") keinem bekannten Score-Bereich zugeordnet werden kann. var domains = ClassTeacherRosterRow.MatchDomains(["Elternkontakt"]); Assert.Equal(3, domains.Count); Assert.Contains(VorgangScoreDomain.Attendance, domains); Assert.Contains(VorgangScoreDomain.Lateness, domains); Assert.Contains(VorgangScoreDomain.Classbook, domains); } [Fact] public void ComputeDampeningCutoffs_NimmtJeBereichDasJuengsteSchliessDatum() { var closed = new List { new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 10) }, new() { Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 20) }, new() { Tags = ["Verspätungen"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 5) }, }; var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed); Assert.Equal(new DateOnly(2026, 8, 20), cutoffs[VorgangScoreDomain.Attendance]); Assert.Equal(new DateOnly(2026, 8, 5), cutoffs[VorgangScoreDomain.Lateness]); Assert.False(cutoffs.ContainsKey(VorgangScoreDomain.Classbook)); } [Fact] public void ComputeDampeningCutoffs_IgnoriertVorgaengeOhneSchliessDatum() { var closed = new List { new() { Tags = ["Absentismus"], Status = VorgangStatus.Open } }; var cutoffs = ClassTeacherRosterRow.ComputeDampeningCutoffs(closed); Assert.Empty(cutoffs); } [Fact] public void PatternScore_BenutztUebergebeneGewichteStattFesterKonstanten() { var student = Student(1001, "Ada Müller"); var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), }; var customWeights = new PatternScoreWeights { UnexcusedDayWeight = 10.0 }; var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), weights: customWeights)); Assert.Equal(10.0, row.PatternScore); } [Fact] public void PatternScore_DaempftNurDenZumVorgangPassendenBereichNachAbschluss() { var student = Student(1001, "Ada Müller"); var yearAbsences = new[] { // Vor dem Abschluss: darf für den Score nicht mehr zählen. new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), // Nach dem Abschluss: zählt weiter. new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), // Verspätung, anderer Bereich: bleibt von der auf "Absentismus" beschränkten Dämpfung unberührt. new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 1), "Müller Ada", 1001, 0, 5, ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), }; var closedVorgaengeByNameKey = new Dictionary> { [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang { Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 12), }], }; var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), closedVorgaengeByNameKey: closedVorgaengeByNameKey)); // 1 unentschuldigter Tag nach Abschluss (3,0) + 1 unbeeinflusster Verspätungstag (0,5) = 3,5. Assert.Equal(3.5, row.PatternScore); // Die Quote bleibt von der Dämpfung unberührt: beide unentschuldigten Tage zählen weiter. Assert.Equal(2, row.YearUnexcusedDayCount); Assert.Equal(2, row.YearAbsenceDayCount); } [Fact] public void PatternScore_VorgangOhneErkennbaresSchlagwortDaemptAlleBereiche() { var student = Student(1001, "Ada Müller"); var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 0, 5, ["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false), }; var closedVorgaengeByNameKey = new Dictionary> { [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang { Title = "Elterngespräch", Tags = ["Elternkontakt"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 12), }], }; var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), closedVorgaengeByNameKey: closedVorgaengeByNameKey)); Assert.Equal(0.0, row.PatternScore); } [Fact] public void RegressionScore_IstNullOhneNeueEreignisseNachAbschluss() { var student = Student(1001, "Ada Müller"); var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), }; var closedVorgaengeByNameKey = new Dictionary> { [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang { Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 12), }], }; var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), closedVorgaengeByNameKey: closedVorgaengeByNameKey)); Assert.Equal(0.0, row.RegressionScore); Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle); } [Fact] public void RegressionScore_ErkenntNeueUnentschuldigteTageNachAbschluss() { var student = Student(1001, "Ada Müller"); var yearAbsences = new[] { new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 5), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), // Nach Abschluss erneut unentschuldigt gefehlt. new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Müller Ada", 1001, 6, 270, ["Deu"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true), }; var closedVorgaengeByNameKey = new Dictionary> { [UntisNameMatching.NameKey("Ada Müller")] = [new Vorgang { Title = "Schuleschwänzen", Tags = ["Absentismus"], Status = VorgangStatus.Closed, ClosedAt = new DateTime(2026, 8, 12), }], }; var row = Assert.Single(ClassTeacherRosterRow.Build([student], [], [], new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20, termStart: new DateOnly(2026, 8, 1), closedVorgaengeByNameKey: closedVorgaengeByNameKey)); Assert.Equal(3.0, row.RegressionScore); Assert.Equal("Schuleschwänzen", row.RegressionVorgangTitle); // Quote weiterhin unangetastet. Assert.Equal(2, row.YearUnexcusedDayCount); } }