CI / build-and-test (push) Canceled after 0s
Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der "Klassenbuch öffnen"-Aktion der Übersicht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
652 lines
29 KiB
C#
652 lines
29 KiB
C#
using LehrerApp.Core.Models;
|
||
using LehrerApp.Desktop.Services;
|
||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||
using Xunit;
|
||
|
||
namespace LehrerApp.Desktop.Tests;
|
||
|
||
public sealed class ClassTeacherViewModelsTests
|
||
{
|
||
[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);
|
||
}
|
||
|
||
[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<CachedUntisHoliday>();
|
||
|
||
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<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]
|
||
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<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||
|
||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||
absences, names, new HashSet<string>());
|
||
|
||
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<string, string>(), new HashSet<string>());
|
||
|
||
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<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
|
||
|
||
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
|
||
absences, names, new HashSet<string> { "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<Student>
|
||
{
|
||
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<Student> { 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<Documentation>
|
||
{
|
||
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<Documentation>
|
||
{
|
||
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<Documentation>
|
||
{
|
||
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));
|
||
}
|
||
}
|