fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
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:
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user