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,
FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
{
lessons ??= new FakeLessons();
lessons.Add(lesson);
@@ -136,7 +136,8 @@ public sealed class DashboardViewModelTests
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
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
@@ -278,20 +279,31 @@ public sealed class DashboardViewModelTests
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]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(2);
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ 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);
}
@@ -300,16 +312,17 @@ public sealed class DashboardViewModelTests
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var today = DateOnly.FromDateTime(FixedNow);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).AddHours(-2);
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
periodSchedule.SetPeriods([new PeriodTimeEntry
{ 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);
}
@@ -40,6 +40,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolWeatherService? _schoolWeather;
private readonly UntisHubService _untisHub;
private readonly WebUntisIntegrationService _webUntis;
private readonly Func<DateTime> _now;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -178,7 +179,8 @@ public partial class DashboardViewModel : ObservableObject
ISubstitutionEntryRepository substitutions, ITimeEntryRepository timeEntries,
UntisHubService untisHub, WebUntisIntegrationService webUntis,
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;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -193,6 +195,11 @@ public partial class DashboardViewModel : ObservableObject
_schoolWeather = schoolWeather;
_untisHub = untisHub;
_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)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
@@ -222,7 +229,7 @@ public partial class DashboardViewModel : ObservableObject
private void Load()
{
var now = DateTime.Now;
var now = _now();
var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear();
@@ -433,7 +440,7 @@ public partial class DashboardViewModel : ObservableObject
.SelectMany(y => _publicHolidays.GetHolidays(y, _calendarSettings.State))
.Select(h => h.Date).ToHashSet();
var schoolHolidays = _schoolHolidays.GetAll();
var nowTime = TimeOnly.FromDateTime(DateTime.Now);
var nowTime = TimeOnly.FromDateTime(_now());
var items = new List<MissingTeachingTimeItem>();
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
(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