Fix early departure classification and calendar markers
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-08 12:04:40 +02:00
1 parent a3cd57e5d8
commit 97eb41a0ea
9 files changed
+72 -19

No files matched your search

@@ -9,6 +9,38 @@ namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests public sealed class ClassTeacherViewModelsTests
{ {
[Theory]
[InlineData("Früher gegangen", 2, 90)]
[InlineData("Vorzeitig verlassen", 0, 20)]
public void FruehesGehen_IstTeilfehlzeitUndImKalenderGesondertErkennbar(string reason, int periods, int minutes)
{
var date = new DateOnly(2026, 9, 3);
var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, periods, minutes,
[], [], ["nicht entsch."], [reason], null, null, true);
Assert.True(row.IsEarlyDeparture);
Assert.False(row.CountsAsFullDay);
Assert.False(row.IsLate);
Assert.True(row.IsPartialAbsence);
var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false));
Assert.Equal("G", marker.Code);
Assert.Equal("#C62828", marker.ColorHex);
Assert.Contains("Früher gegangen", marker.Tooltip);
Assert.Contains("Unentschuldigt", marker.Tooltip);
Assert.DoesNotContain("Ganzer Fehltag", marker.Tooltip);
}
[Fact]
public void Kalender_GanzeFehltageBehaltenEigenesKuerzel()
{
var date = new DateOnly(2026, 9, 3);
var row = new ClassAbsenceDaySummaryRow(date, "Müller Ada", 1, 6, 270,
[], [], ["nicht entsch."], ["Absent"], null, null, true);
var marker = Assert.Single(ClassTeacherDetailsViewModel.BuildDayEvents(date, [row], [], false));
Assert.Equal("U", marker.Code);
Assert.Contains("Ganzer Fehltag", marker.Tooltip);
}
[Fact] [Fact]
public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus() public void Monatskalender_KodiertFehlzeitenUndBlendetHausaufgabenStandardmaessigAus()
{ {
@@ -32,7 +64,7 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(0, days.Count % 5); 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 == "V");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "U"); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "T");
Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!"); Assert.Contains(days.SelectMany(d => d.Events), e => e.Code == "!");
Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H"); Assert.DoesNotContain(days.SelectMany(d => d.Events), e => e.Code == "H");
@@ -65,10 +97,10 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(30, days.Count); Assert.Equal(30, days.Count);
Assert.False(days[0].HasSignal); Assert.False(days[0].HasSignal);
Assert.Equal("1", days[0].DayNumber); Assert.Equal("1", days[0].DayNumber);
Assert.Equal("U", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag Assert.Equal("T", days[2].SignalCode); // stärker als der parallele Klassenbucheintrag
Assert.True(days[2].IsToday); Assert.True(days[2].IsToday);
Assert.Contains("Ada Müller", days[2].Tooltip); Assert.Contains("Ada Müller", days[2].Tooltip);
Assert.Equal("E", days[3].SignalCode); Assert.Equal("T", days[3].SignalCode);
Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet Assert.False(days[4].HasSignal); // Hausaufgaben bleiben im Widget bewusst ausgeblendet
} }
@@ -87,7 +119,7 @@ public sealed class ClassTeacherViewModelsTests
var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month, var days = ClassTeacherOverviewViewModel.BuildCompactMonthDays(month, absences, [], month,
"Ada Müller"); "Ada Müller");
Assert.Equal("U", days[2].SignalCode); Assert.Equal("T", days[2].SignalCode);
Assert.False(days[3].HasSignal); Assert.False(days[3].HasSignal);
} }
@@ -107,7 +139,7 @@ public sealed class ClassTeacherViewModelsTests
Assert.True(drawing.ContentHeight > 0); Assert.True(drawing.ContentHeight > 0);
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller"); Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "Ada Müller");
Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "U"); Assert.Contains(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "T");
Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E"); Assert.DoesNotContain(drawing.Commands.OfType<DrawStringEx>(), c => c.Text == "E");
} }
@@ -151,7 +151,7 @@ public static class StudentAttendanceCalendarDrawingBuilder
y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + designTrailingGapY * k; y = gridStartY + 11 * k + 9 * k + maxWeeks * cellHeight + designTrailingGapY * k;
commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign, commands.Add(new DrawStringEx(0, y, 8 * k, contentWidthDesign,
"U unentschuldigt · A abwesend · V verspätet · E entschuldigt · ! Klassenbuch", "U/E/A ganztägig · T teilweise · G gegangen · V verspätet · ! Klassenbuch",
DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280")); DrawingTextAlignment.AlignLeft, 6.5f * finalScale, Color: "#6B7280"));
return new DrawingValue(commands, y + 9 * k); return new DrawingValue(commands, y + 9 * k);
} }
@@ -34,7 +34,7 @@ public sealed class UntisReportCacheService(
{ {
private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1); private static readonly TimeSpan HotWindowRefreshInterval = TimeSpan.FromHours(1);
private const int HotWindowDays = 14; private const int HotWindowDays = 14;
private const int AbsenceReportVersion = 1; private const int AbsenceReportVersion = 2;
/// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer /// Roster hat anders als Fehlzeiten/Klassenbuch keine Historie (nur "aktueller Stand einer
/// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch". /// Klasse") - deshalb ohne heißes/kaltes Fenster, nur "gilt der letzte Abruf noch als frisch".
@@ -66,6 +66,17 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons, IReadOnlyList<int> PeriodNumbers, IReadOnlyList<string> Statuses, IReadOnlyList<string> AbsenceReasons,
string? Note, string? ExcuseNote, bool CountsAsFullDay) string? Note, string? ExcuseNote, bool CountsAsFullDay)
{ {
public bool CountsAsFullDay { get; init; } = CountsAsFullDay && !HasEarlyDepartureReason(AbsenceReasons);
public bool IsEarlyDeparture => HasEarlyDepartureReason(AbsenceReasons);
private static bool HasEarlyDepartureReason(IEnumerable<string> reasons) => reasons.Any(reason =>
reason.Contains("vorzeitig", StringComparison.OrdinalIgnoreCase) ||
((reason.Contains("früh", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("frueh", StringComparison.OrdinalIgnoreCase)) &&
(reason.Contains("geh", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("gegang", StringComparison.OrdinalIgnoreCase) ||
reason.Contains("verlass", StringComparison.OrdinalIgnoreCase))));
/// Nur für die Gewichtung von stundenweisen Fehlzeiten im Auffälligkeitsscore. Zeugnis- und /// Nur für die Gewichtung von stundenweisen Fehlzeiten im Auffälligkeitsscore. Zeugnis- und
/// Anzeigezahlen verwenden weiterhin die echten WebUntis-Felder CountsAsFullDay bzw. /// Anzeigezahlen verwenden weiterhin die echten WebUntis-Felder CountsAsFullDay bzw.
/// TotalAbsentPeriods und rechnen keine Stunden künstlich in Fehltage um. /// TotalAbsentPeriods und rechnen keine Stunden künstlich in Fehltage um.
@@ -84,8 +95,8 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order()); public string PeriodsLabel => string.Join(", ", PeriodNumbers.Order());
public string StatusLabel => string.Join(", ", Statuses); public string StatusLabel => string.Join(", ", Statuses);
public string ReasonLabel => string.Join("; ", AbsenceReasons); public string ReasonLabel => string.Join("; ", AbsenceReasons);
public bool IsLate => TotalAbsentPeriods == 0 || AbsenceReasons.Any(r => public bool IsLate => !IsEarlyDeparture && (TotalAbsentPeriods == 0 || AbsenceReasons.Any(r =>
r.Contains("verspät", StringComparison.OrdinalIgnoreCase)); r.Contains("verspät", StringComparison.OrdinalIgnoreCase)));
public bool IsUnexcused => Statuses.Any(s => public bool IsUnexcused => Statuses.Any(s =>
s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase)); s.Contains("nicht entsch", StringComparison.OrdinalIgnoreCase));
public bool IsPartialAbsence => !CountsAsFullDay && (!IsLate || IsUnexcused); public bool IsPartialAbsence => !CountsAsFullDay && (!IsLate || IsUnexcused);
@@ -93,7 +104,9 @@ public sealed record ClassAbsenceDaySummaryRow(DateOnly Date, string StudentName
Math.Min(1d, (TotalAbsentPeriods > 0 Math.Min(1d, (TotalAbsentPeriods > 0
? TotalAbsentPeriods ? TotalAbsentPeriods
: TotalAbsentMinutes / 45d) / TypicalPeriodsPerSchoolDayForScoring); : TotalAbsentMinutes / 45d) / TypicalPeriodsPerSchoolDayForScoring);
public string KindLabel => IsLate ? $"{TotalAbsentMinutes} Min. verspätet" : public string KindLabel => IsEarlyDeparture
? $"Früher gegangen · {(TotalAbsentPeriods > 0 ? $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}" : $"{TotalAbsentMinutes} Fehlminuten")}" :
IsLate ? $"{TotalAbsentMinutes} Min. verspätet" :
CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}"; CountsAsFullDay ? "Ganzer Fehltag" : $"{TotalAbsentPeriods} Fehlstunde{(TotalAbsentPeriods == 1 ? "" : "n")}";
public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" : public string FriendlyStatusLabel => IsUnexcused ? "Unentschuldigt" :
Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel; Statuses.Any(s => s.Contains("entsch", StringComparison.OrdinalIgnoreCase)) ? "Entschuldigt" : StatusLabel;
@@ -410,6 +423,13 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
: absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase) : absence.FriendlyStatusLabel.Contains("Entschuldigt", StringComparison.OrdinalIgnoreCase)
? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused) ? ("E", "Entschuldigt gefehlt", "#2E7D32", ClassTeacherCalendarEventKind.Excused)
: ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent); : ("A", "Abwesend", "#1565C0", ClassTeacherCalendarEventKind.Absent);
// Umfang und Entschuldigungsstatus sind unabhängig: Rot kann auch zwei
// unentschuldigte Fehlstunden bedeuten, nicht zwingend einen ganzen Fehltag.
if (absence.IsPartialAbsence && !absence.IsLate)
code = absence.IsEarlyDeparture ? "G" : "T";
else if (absence.IsLate)
code = "V";
label = $"{absence.KindLabel} · {absence.FriendlyStatusLabel}";
events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind)); events.Add(new ClassTeacherCalendarEvent(absence.StudentDisplayName, code, label, color, kind));
} }
@@ -50,11 +50,7 @@ public sealed record ClassTeacherRosterRow(string StudentName, int? ExternKey, b
{ {
if (TodayAbsence is null) if (TodayAbsence is null)
return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit"; return HasRecentClassRegisterEntry ? "Neuer Klassenbucheintrag" : "Keine gemeldete Fehlzeit";
var core = IsLate var core = TodayAbsence.KindLabel;
? $"{TodayAbsence.TotalAbsentMinutes} Min. verspätet"
: TodayAbsence.CountsAsFullDay
? "Ganzer Fehltag"
: $"{TodayAbsence.TotalAbsentPeriods} Fehlstunde{(TodayAbsence.TotalAbsentPeriods == 1 ? "" : "n")}";
return IsUnexcused ? $"{core} · Unentschuldigt" : core; return IsUnexcused ? $"{core} · Unentschuldigt" : core;
} }
} }
@@ -432,7 +432,7 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
AutomationProperties.Name="Schüler für Monatskalender auswählen"/> AutomationProperties.Name="Schüler für Monatskalender auswählen"/>
<shared:CompactMonthCalendar Days="{Binding CompactMonthDays}"/> <shared:CompactMonthCalendar Days="{Binding CompactMonthDays}"/>
<TextBlock Text="U unentsch. · ! Klassenbuch · A abwesend · V verspätet · E entsch." <TextBlock Text="U/E/A ganzer Fehltag · T Teilfehlzeit · G früher gegangen · V verspätet · ! Klassenbuch. Rot: unentschuldigt."
FontSize="9" Opacity="0.5" TextWrapping="Wrap"/> FontSize="9" Opacity="0.5" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -293,7 +293,6 @@ public sealed class WebUntisClientTests
Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri); Assert.Contains("reports.do?name=ClassregEventPerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri); Assert.Contains("klasseOrStudentgroupId=-1", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri); Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri); Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri); Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri); Assert.EndsWith("reports.do?get=rpt2.tmp&name=ClassregEventPerStudent&format=csv", handler.Requests[2].Uri);
@@ -341,6 +340,7 @@ public sealed class WebUntisClientTests
Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri); Assert.Contains("reports.do?name=AbsencePerStudent", handler.Requests[1].Uri);
Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri); Assert.Contains("klasseOrStudentgroupId=KL874", handler.Requests[1].Uri);
Assert.Contains("studentId=-1", handler.Requests[1].Uri); Assert.Contains("studentId=-1", handler.Requests[1].Uri);
Assert.Contains("&separateAbsentDays=true&", handler.Requests[1].Uri);
Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri); Assert.Contains("rpt_sd=20260824", handler.Requests[1].Uri);
Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri); Assert.Contains("rpt_ed=20260828", handler.Requests[1].Uri);
Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri); Assert.EndsWith("reports.do?get=rpt3.tmp&name=AbsencePerStudent&format=csv", handler.Requests[2].Uri);
+2 -2
View File
@@ -179,7 +179,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" + "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" + "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" + "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
"&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" + "&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" + "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" + "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" + "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
@@ -208,7 +208,7 @@ public sealed partial class WebUntisClient : IAsyncDisposable
"&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" + "&calendarChange=0&withAbsences=true&_withAbsences=on&withLateness=true&_withLateness=on" +
"&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" + "&excludeNotCountingAbsences=true&_excludeNotCountingAbsences=on&_allStudents=on" +
"&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" + "&absStudGroup=1&studExcuseStatusId=-1&_withoutPageBreaksAbsPerStudent=on&absClassGroup=1" +
"&classExcuseStatusId=-1&_absClassSorted=on&_separateAbsentDays=on&_filterAbsencesKlasse=on" + "&classExcuseStatusId=-1&_absClassSorted=on&separateAbsentDays=true&_separateAbsentDays=on&_filterAbsencesKlasse=on" +
"&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" + "&absSubjectGroup=7&absSubjectId=-1&absSubjectGroupGroup=7&subjectGroupId=-1&_totalDays=on" +
"&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" + "&absentPeriodsLimit=16&reportWeeks=4&dayLimit=3&excuseGroup=1&_groupPerWeek=on" +
"&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" + "&_excuseStatusAll=on&studEventReasonId=-1&classEventReasonId=-1&_evntClassSorted=on" +
+5
View File
@@ -1622,6 +1622,11 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler. Klasse+Fach aus der Zeile scopen (→ genau eine lokale Lerngruppe), nicht global über alle Schüler.
Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut. Abgleichsdialog (Diff gegen `Documentation`, Übernehmen in beide Richtungen) noch nicht gebaut.
- [x] **Klassenlehrer — einzelne Fehlstunden als ganze Fehltage (Oktober 2026):** - [x] **Klassenlehrer — einzelne Fehlstunden als ganze Fehltage (Oktober 2026):**
- Nachkorrektur: Der Trennparameter und seine Regression gehören zum Fehlzeitenbericht,
nicht zum gleich aufgebauten Klassenbuchbericht. Cacheversion 2 erneuert die alten Abrufe.
Explizites früheres Gehen im Abwesenheitsgrund zählt als Teilfehlzeit. Kalender zeigen
`G` für früheres Gehen und `T` für sonstige Teilfehlzeiten, mit Umfang/Status im Tooltip;
Rot kennzeichnet weiterhin unentschuldigte Fehlzeiten. Ganze Fehltage behalten U/E/A.
Beim Berichtabruf ist `separateAbsentDays=true` jetzt tatsächlich aktiviert; der bisherige Beim Berichtabruf ist `separateAbsentDays=true` jetzt tatsächlich aktiviert; der bisherige
Parameter `_separateAbsentDays=on` allein aktivierte die Checkbox nicht. Alte Fehlzeiten-Caches Parameter `_separateAbsentDays=on` allein aktivierte die Checkbox nicht. Alte Fehlzeiten-Caches
werden über eine Berichtversion neu geladen und auch im historischen Bereich ersetzt. werden über eine Berichtversion neu geladen und auch im historischen Bereich ersetzt.