fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s

TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

DashboardViewModel erhaelt einen optionalen Func<DateTime>? now-Konstruktorparameter (Default
DateTime.Now, gleiches Muster wie die bereits bestehenden optionalen annualPlanEvents/
schoolWeather-Parameter) und nutzt ihn statt direkter DateTime.Now-Aufrufe in Load() und
LoadMissingTeachingTime. Die beiden betroffenen Tests injizieren jetzt einen festen
Referenzzeitpunkt (ein Feiertags-freier Dienstag im September) und sind damit unabhaengig
von der tatsaechlichen Ausfuehrungsuhrzeit deterministisch gruen.

Details siehe TODO.md, Abschnitt 9 (Dashboard).
This commit is contained in:
2026-09-11 01:19:29 +02:00
parent 6af4bee1f0
commit 455c61c946
3 changed files with 46 additions and 13 deletions
@@ -122,7 +122,7 @@ public sealed class DashboardViewModelTests
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null, FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null, FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null) FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
{ {
lessons ??= new FakeLessons(); lessons ??= new FakeLessons();
lessons.Add(lesson); lessons.Add(lesson);
@@ -136,7 +136,8 @@ public sealed class DashboardViewModelTests
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents); TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
annualPlanSync: null, schoolWeather: null, now: now);
} }
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
@@ -278,20 +279,31 @@ public sealed class DashboardViewModelTests
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
} }
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
[Fact] [Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet() public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2); var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]); { PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today); Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
@@ -300,16 +312,17 @@ public sealed class DashboardViewModelTests
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet() public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{ {
var group = new LearningGroup { Name = "9c" }; var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today); var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots(); var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule(); var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück. // Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2); var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry periodSchedule.SetPeriods([new PeriodTimeEntry
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]); { PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
periodSchedule: periodSchedule, now: () => FixedNow);
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today); Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
} }
@@ -40,6 +40,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolWeatherService? _schoolWeather; private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub; private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis; private readonly WebUntisIntegrationService _webUntis;
private readonly Func<DateTime> _now;
private const int OpenExcuseMaxAgeDays = 21; private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14; private const int SupportPlanDueWithinDays = 14;
@@ -178,7 +179,8 @@ public partial class DashboardViewModel : ObservableObject
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries, ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis, UntisHubService untisHub, WebUntisIntegrationService webUntis,
IAnnualPlanEventRepository? annualPlanEvents = null, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null) AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null,
Func<DateTime>? now = null)
{ {
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -193,6 +195,11 @@ public partial class DashboardViewModel : ObservableObject
_schoolWeather = schoolWeather; _schoolWeather = schoolWeather;
_untisHub = untisHub; _untisHub = untisHub;
_webUntis = webUntis; _webUntis = webUntis;
// Testbare Uhr statt direkter DateTime.Now-Aufrufe (siehe Load()/LoadMissingTeachingTime):
// TimeOnly.AddHours()/AddMinutes() wickelt bei Mitternacht um, ohne injizierbares "jetzt"
// waeren Tests fuer "kurz vor/nach Ablauf einer Wartezeit" je nach Ausfuehrungsuhrzeit
// zufaellig rot oder gruen (siehe TODO.md, Abschnitt 9, Nachtrag Uhrzeit-Wraparound).
_now = now ?? (() => DateTime.Now);
if (annualPlanSync is not null) if (annualPlanSync is not null)
{ {
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
@@ -222,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
private void Load() private void Load()
{ {
var now = DateTime.Now; var now = _now();
var today = DateOnly.FromDateTime(now); var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De); CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear(); CurrentSchoolYear = _sy.CurrentSchoolYear();
@@ -433,7 +440,7 @@ public partial class DashboardViewModel : ObservableObject
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State)) .SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet(); .Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll(); var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now); var nowTime = TimeOnly.FromDateTime(_now());
var items = new List<MissingTeachingTimeItem>(); var items = new List<MissingTeachingTimeItem>();
for (var date = firstDay; date <= today; date = date.AddDays(1)) for (var date = firstDay; date <= today; date = date.AddDays(1))
+13
View File
@@ -3265,6 +3265,19 @@ Sitzungszustand (keine Persistenz in `dashboardsettings.json`, das dortige Forma
Kachel-Liste ohne Platz für Sub-Filter je Art). "Klausurwochen" bleibt weiterhin eine eigene Kachel Kachel-Liste ohne Platz für Sub-Filter je Art). "Klausurwochen" bleibt weiterhin eine eigene Kachel
(Wochen-Aggregat, kein Einzelvorgang zum Abhaken/Anklicken). (Wochen-Aggregat, kein Einzelvorgang zum Abhaken/Anklicken).
**Nachtrag Uhrzeit-Wraparound-Bug (September 2026):** Zwei `MissingTeachingTime`-Tests bauten ihr
"Unterrichtsende" bisher aus `TimeOnly.FromDateTime(DateTime.Now).AddHours(±2)`. `TimeOnly` wickelt
bei Mitternacht um (kein Datumsanteil) — bei Testausführung zwischen ca. 22:00 und 02:00 Uhr wurde
aus "+2 Stunden" dadurch ein Ende, das *vor* statt nach dem echten Ablauf des 30-Minuten-Fensters
lag (oder umgekehrt bei "-2 Stunden"), je nach Ausführungsuhrzeit zufällig rot. `DashboardViewModel`
erhält deshalb einen optionalen `Func<DateTime>? now`-Konstruktorparameter (Default `DateTime.Now`,
gleiches Muster wie die bestehenden optionalen `annualPlanEvents`/`schoolWeather`-Parameter — DI
löst unregistrierte optionale Parameter über ihren Default auf) und nutzt ihn in `Load()` sowie
`LoadMissingTeachingTime` statt direkter `DateTime.Now`-Aufrufe. Die beiden betroffenen Tests
injizieren jetzt einen festen Referenzzeitpunkt (`FixedNow`, ein Dienstag im September — bewusst
gewählt, weil in diesem Monat kein bundesweiter oder länderspezifischer Feiertag liegt) und sind
damit unabhängig von der tatsächlichen Ausführungsuhrzeit deterministisch grün.
--- ---
## 10. Sync & Server ## 10. Sync & Server