using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels; using System.Globalization; using Xunit; namespace LehrerApp.Desktop.Tests; /// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer /// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der /// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine /// (offene Aufgaben, ...). public sealed class DashboardViewModelTests { [Fact] public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); Assert.False(vm.ExcusesCard.EffectiveIsVisible); Assert.False(vm.CorrectionsCard.EffectiveIsVisible); Assert.False(vm.AlertsCard.EffectiveIsVisible); Assert.Equal("0 offene Punkte", vm.AttentionSummary); Assert.True(vm.TodayCard.EffectiveIsVisible); Assert.True(vm.CalendarCard.EffectiveIsVisible); } /// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine /// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das /// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind. [Fact] public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); // Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen. Assert.False(vm.ExcusesCard.EffectiveIsVisible); var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible) .Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList(); Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze); } [Fact] public void KachelMargin_FolgtDerBerechnetenSpalte() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible)) Assert.Equal(card.Column == 0 ? new Avalonia.Thickness(0, 0, 8, 8) : new Avalonia.Thickness(8, 0, 0, 8), card.Margin); } /// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden /// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite. [Fact] public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }); Assert.Equal(1, vm.TasksCard.Column); Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin); vm.TodayCard.IsVisible = false; Assert.Equal(0, vm.TasksCard.Column); Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin); } private static PeriodScheduleService NewPeriodSchedule() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-dashboardvm-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); return new PeriodScheduleService(tempPath); } private static DashboardSettingsService NewDashboardSettings() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-dashboardsettings-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); return new DashboardSettingsService(tempPath); } private static SchoolCalendarSettingsService NewCalendarSettings() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); return new SchoolCalendarSettingsService(tempPath); } private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson, FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null, FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null, FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null, FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, FakeSessions? sessions = null, FakeEntries? entries = null, FakeAnnualPlanEvents? annualPlanEvents = null, List? allGroups = null, FakeTimeEntries? timeEntries = null) { lessons ??= new FakeLessons(); lessons.Add(lesson); return new DashboardViewModel( new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons, exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(), reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]), tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents); } /// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der /// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist. private static DateOnly NextIsoWeekMonday() { var reference = DateTime.Today.AddDays(7); return DateOnly.FromDateTime( ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday)); } [Fact] public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var monday = NextIsoWeekMonday(); var exams = new FakeExams([ new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned }, new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned }, new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned }, ]); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); var entry = Assert.Single(vm.ExamWeekLoads); Assert.Equal(3, entry.ExamCount); Assert.True(vm.ExamLoadCard.EffectiveIsVisible); } [Fact] public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var monday = NextIsoWeekMonday(); var exams = new FakeExams([ new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned }, new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned }, ]); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); Assert.Empty(vm.ExamWeekLoads); } [Fact] public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var monday = NextIsoWeekMonday(); var exams = new FakeExams([ new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted }, new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded }, new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned }, ]); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams); Assert.Empty(vm.ExamWeekLoads); } [Fact] public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var pastDay = today.AddDays(-1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule); Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); Assert.True(vm.MissingTeachingTimeCard.EffectiveIsVisible); Assert.Equal(2, vm.AttentionCount); // fehlende Unterrichtszeit + bereits bestehende ungeplante Stunde } [Fact] public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var pastDay = today.AddDays(-1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var timeEntries = new FakeTimeEntries(); timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries); Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); } [Fact] public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var pastDay = today.AddDays(-1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var substitutions = new FakeSubstitutionEntries(); substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, substitutions: substitutions); Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); } [Fact] public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var pastDay = today.AddDays(-1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var schoolHolidays = new FakeSchoolHolidays(); schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays); Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay); } [Fact] public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); 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); 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); Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today); } [Fact] public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); 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); 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); Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today); } [Fact] public void TodaysLessons_LoestRaumUeberPassendenStundenplanSlotAuf() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3, Room = "R204" }); var vm = BuildVm(group, lesson, slots: slots); var item = Assert.Single(vm.TodaysLessons); Assert.Equal("R204", item.Room); Assert.True(item.HasRoom); } [Fact] public void TodaysLessons_OhnePassendenSlot_HatKeinenRaum() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" }; var vm = BuildVm(group, lesson); var item = Assert.Single(vm.TodaysLessons); Assert.False(item.HasRoom); Assert.Equal("", item.Room); } [Fact] public void TodaysLessons_ZeitKommtAusLessonStartTimeWennGesetzt() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox", StartTime = new TimeOnly(9, 15), }; var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(10, 0), End = new TimeOnly(10, 45) }]); var vm = BuildVm(group, lesson, periodSchedule: periodSchedule); Assert.Equal("09:15", Assert.Single(vm.TodaysLessons).TimeDisplay); } [Fact] public void TodaysLessons_OhneStartTime_FaelltAufStundenrasterZurueck() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" }; var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(10, 0), End = new TimeOnly(10, 45) }]); var vm = BuildVm(group, lesson, periodSchedule: periodSchedule); Assert.Equal("10:00", Assert.Single(vm.TodaysLessons).TimeDisplay); } [Fact] public void OpenLesson_RuftNavigationMitGruppenIdAuf() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" }; var vm = BuildVm(group, lesson); Guid? navigatedTo = null; vm.OnNavigateToLesson = id => navigatedTo = id; vm.OpenLessonCommand.Execute(vm.TodaysLessons[0]); Assert.Equal(group.Id, navigatedTo); } [Fact] public void UpcomingDates_BuendeltKlausurenFristenUndFoerderplanPruefungen() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var exams = new FakeExams([ new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = today.AddDays(5) }, ]); var tasks = new FakeWorkTasks(); tasks.Add(new WorkTask { Title = "Notenschluss", DueDate = today.AddDays(10) }); var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; var documentation = new FakeDocumentation(); documentation.Add(new Documentation { StudentId = student.Id, Type = DocumentationType.SupportPlan, Title = "Leseförderung", SupportData = new SupportData { Status = SupportStatus.Active, ReviewDate = today.AddDays(3) }, }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams, tasks: tasks, students: new FakeStudents([student]), documentation: documentation); Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Exam && i.Title == "Chemie-Test"); Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.Deadline && i.Title == "Notenschluss"); Assert.Contains(vm.UpcomingDates, i => i.Kind == UpcomingDateKind.SupportPlan && i.StudentId == student.Id); } [Fact] public void OpenCorrections_ZeigtBewertungsfortschrittJeKlausur() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Date = today.AddDays(-2), Status = ExamStatus.Conducted }; var anna = new Student { FirstName = "Anna", LastName = "A" }; var ben = new Student { FirstName = "Ben", LastName = "B" }; var memberships = new FakeMemberships([ new GroupMembership { GroupId = group.Id, StudentId = anna.Id }, new GroupMembership { GroupId = group.Id, StudentId = ben.Id }, ]); var results = new FakeResults(); results.Add(new ExamResult { ExamId = exam.Id, StudentId = anna.Id, Grade = "2" }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: new FakeExams([exam]), results: results, memberships: memberships, students: new FakeStudents([anna, ben])); var correction = Assert.Single(vm.OpenCorrections); Assert.Equal(1, correction.Completed); Assert.Equal(2, correction.Total); Assert.Equal(50, correction.Percent); } [Fact] public void Alerts_ErkenntDeutlichenNotenabfall() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c", GradingSystem = GradingSystem.Grades1To6 }; var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; var memberships = new FakeMemberships([ new GroupMembership { GroupId = group.Id, StudentId = student.Id }, ]); var grades = new FakeGrades(); grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-20), Value = "2" }); grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-15), Value = "2" }); grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-10), Value = "4" }); grades.Add(new Grade { GroupId = group.Id, StudentId = student.Id, Date = today.AddDays(-5), Value = "4" }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades, memberships: memberships, students: new FakeStudents([student])); Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall"); } [Fact] public void Kalenderauswahl_ZeigtTermineDesGeklicktenTages() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var examDate = today.AddDays(2); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: new FakeExams([new Exam { GroupId = group.Id, Title = "Test", Date = examDate }])); var day = vm.CalendarDays.Single(d => d.Date == examDate); vm.SelectCalendarDayCommand.Execute(day); Assert.True(day.IsSelected); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test"); } [Fact] public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest() { var today = DateOnly.FromDateTime(DateTime.Today); var selectedDate = today.AddDays(1); var alphabeticallyFirst = new LearningGroup { Name = "10a" }; var selectedDayGroup = new LearningGroup { Name = "WAT 10c" }; var lessons = new FakeLessons(); lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate }); var vm = BuildVm(alphabeticallyFirst, new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons, allGroups: [alphabeticallyFirst, selectedDayGroup]); vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate)); Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId); Assert.True(vm.CurrentGroups[0].IsOnSelectedDay); Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId); } [Fact] public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var sessionDate = today.AddDays(3); var session = new ParticipationSession { GroupId = group.Id, Date = sessionDate, Comment = "Aufsatz" }; var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, sessions: new FakeSessions([session])); var day = vm.CalendarDays.Single(d => d.Date == sessionDate); Assert.True(day.HasSession); vm.SelectCalendarDayCommand.Execute(day); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); } [Fact] public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var annualPlan = new FakeAnnualPlanEvents(); annualPlan.Add(new AnnualPlanEvent { ExternalId = "fahrt", Title = "Klassenfahrt 6a", Description = "Aushang beachten", Location = "Jugendherberge", CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2), StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false, }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" }, annualPlanEvents: annualPlan); var day = vm.CalendarDays.Single(d => d.Date == today); Assert.True(day.HasLesson); Assert.True(day.HasAnnualPlanEvent); vm.SelectCalendarDayCommand.Execute(day); Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson); var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan); Assert.Equal("Klassenfahrt 6a", annual.Title); Assert.Equal("Aushang beachten", annual.Description); Assert.Contains("13:30", annual.Subtitle); Assert.Null(annual.GroupId); } [Fact] public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag() { // Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über // LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich // als eigener Sitzungstermin auftauchen. var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" }; var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" }; var vm = BuildVm(group, lesson, sessions: new FakeSessions([session])); var day = vm.CalendarDays.Single(d => d.Date == today); Assert.True(day.HasLesson); Assert.False(day.HasSession); vm.SelectCalendarDayCommand.Execute(day); Assert.Single(vm.SelectedDayEvents); Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession); } [Fact] public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem() { // Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan // gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde. var today = DateOnly.FromDateTime(DateTime.Today); var tomorrow = today.AddDays(1); var group = new LearningGroup { Name = "9c", IsOwnClass = true }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 }); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots); var day = vm.CalendarDays.Single(d => d.Date == tomorrow); Assert.True(day.IsOwnClassDay); } [Fact] public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert() { var settings = NewDashboardSettings(); var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, dashboardSettings: settings); vm.TodayCard.IsVisible = false; vm.MoveCardDownCommand.Execute(vm.TasksCard); var saved = settings.Load(); Assert.False(saved.Single(c => c.Key == "today").IsVisible); Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1); } // ── Ungeplante Stunden (Nutzer-Feedback) ────────────────────────────────── [Fact] public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); // "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen- // Vorgriff fällt und den Test verfälscht. var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" }; var vm = BuildVm(group, pastLesson, slots: slots); var item = Assert.Single(vm.UnplannedLessons); Assert.Equal(group.Id, item.GroupId); Assert.Equal(1, item.PeriodNumber); Assert.Equal("Heute", item.DateDisplay); } [Fact] public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redoxreaktionen" }; var vm = BuildVm(group, lesson, slots: slots); Assert.Empty(vm.UnplannedLessons); } [Fact] public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" }; var vm = BuildVm(group, pastLesson, slots: slots); Assert.Empty(vm.UnplannedLessons); } [Fact] public void UnplannedLessons_Feiertag_WirdUebersprungen() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 }); var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" }; var schoolHolidays = new FakeSchoolHolidays(); schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) }); var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays); Assert.Empty(vm.UnplannedLessons); } [Fact] public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 }); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" }; var vm = BuildVm(group, lesson, slots: slots); Assert.Empty(vm.UnplannedLessons); } [Fact] public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 }); var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" }; var vm = BuildVm(group, lesson, slots: slots); Assert.Equal(2, vm.UnplannedLessons.Count); Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3); Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4); } [Fact] public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 }); slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 }); var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" }; var substitutions = new FakeSubstitutionEntries(); substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 }); substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 }); var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions); Assert.Empty(vm.UnplannedLessons); } [Fact] public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; var sessions = new FakeSessions([]); var entries = new FakeEntries(); // Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung. for (var i = 0; i < 3; i++) { var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; sessions.Save(session); entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused }); } var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, students: new FakeStudents([student]), sessions: sessions, entries: entries); Assert.Empty(vm.AttendanceWarnings); } [Fact] public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe() { var today = DateOnly.FromDateTime(DateTime.Today); var group = new LearningGroup { Name = "9c" }; var student = new Student { FirstName = "Anna", LastName = "Beispiel" }; var sessions = new FakeSessions([]); var entries = new FakeEntries(); // 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe. for (var i = 0; i < 10; i++) { var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) }; sessions.Save(session); var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present; entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status }); } var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, students: new FakeStudents([student]), sessions: sessions, entries: entries); var item = Assert.Single(vm.AttendanceWarnings); Assert.Equal(student.FullName, item.StudentName); Assert.Equal(30.0, item.AbsenceRatePercent); } [Fact] public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var tasks = new FakeWorkTasks(); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks); vm.OnAddTask = startAsReminder => Task.FromResult( new WorkTask { Title = "Ansage an die Klasse", Kind = startAsReminder ? TaskKind.Reminder : TaskKind.WorkItem }); await vm.AddReminderCommand.ExecuteAsync(null); Assert.Single(tasks.GetAll()); Assert.Contains(vm.OpenTasks, t => t.Title == "Ansage an die Klasse" && t.IsReminder); } [Fact] public async Task AddTask_OhneDelegatMachtNichts() { var group = new LearningGroup { Name = "9c" }; var today = DateOnly.FromDateTime(DateTime.Today); var tasks = new FakeWorkTasks(); var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks); await vm.AddTaskCommand.ExecuteAsync(null); Assert.Empty(tasks.GetAll()); } [Fact] public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf() { var warning = new WeatherWarning { Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter", Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.", Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc), Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc), }; var item = new DashboardWeatherWarningItem(warning); Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline); Assert.Equal("#D32F2F", item.SeverityColor); Assert.Contains("Uhr", item.PeriodDisplay); } }