diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 869e221..bfe0aac 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -122,7 +122,7 @@ public sealed class DashboardViewModelTests FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeAnnualPlanEvents? annualPlanEvents = null, List? allGroups = null, - FakeTimeEntries? timeEntries = null) + FakeTimeEntries? timeEntries = null, Func? 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); } diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index d20ca5f..fcbec60 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -40,6 +40,7 @@ public partial class DashboardViewModel : ObservableObject private readonly SchoolWeatherService? _schoolWeather; private readonly UntisHubService _untisHub; private readonly WebUntisIntegrationService _webUntis; + private readonly Func _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? 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(); for (var date = firstDay; date <= today; date = date.AddDays(1)) diff --git a/TODO.md b/TODO.md index 643bcb6..1600265 100644 --- a/TODO.md +++ b/TODO.md @@ -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? 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