using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Groups; using Xunit; namespace LehrerApp.Desktop.Tests; public sealed class LessonDialogViewModelTests { private static PeriodScheduleService NewPeriodSchedule() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-lessondialogvm-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); return new PeriodScheduleService(tempPath); } private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null, FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) => new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], editing); /// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere /// LearningGroup-Datensätze mit gleichem Namen), war im Stunden-Dialog nicht erkennbar, /// welche der beiden Stunden man gerade bearbeitet. [Fact] public void GroupSubjectDisplay_ZeigtGruppeUndFachZusammen() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); Assert.Equal("10c · Chemie", vm.GroupSubjectDisplay); } [Fact] public void AddPhase_FuegtZeileMitStandardwertenHinzu() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.AddPhaseCommand.Execute(null); Assert.Single(vm.Phases); Assert.Equal(5, vm.Phases[0].DurationMinutes); Assert.False(vm.Phases[0].HasAlternativePath); } [Fact] public void RemovePhase_EntferntZeileUndAktualisiertGesamtdauer() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 20; vm.Phases[0].RemoveCommand.Execute(null); Assert.Empty(vm.Phases); Assert.Equal("0 Minuten gesamt", vm.TotalDurationDisplay); } [Fact] public void MoveDown_VertauschtReihenfolgeDerPhasen() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.AddPhaseCommand.Execute(null); vm.Phases[0].Name = "Einstieg"; vm.AddPhaseCommand.Execute(null); vm.Phases[1].Name = "Erarbeitung"; vm.Phases[0].MoveDownCommand.Execute(null); Assert.Equal("Erarbeitung", vm.Phases[0].Name); Assert.Equal("Einstieg", vm.Phases[1].Name); } [Fact] public void Shorthand_IstFreitextOhneErzwungeneVonNachStruktur() { // Kurzsymbol muss nicht immer ein Materialfluss-Pfeil sein — manchmal nur eine // Sozialform ohne Pfeil (z.B. "Plenum", "LDE"). var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.AddPhaseCommand.Execute(null); var phase = vm.Phases[0]; phase.Shorthand = "Plenum"; Assert.Equal("Plenum", phase.Shorthand); phase.Shorthand = "AB001->S"; Assert.Equal("AB001->S", phase.Shorthand); } [Fact] public void ShorthandSuggestions_KombiniertKatalogUndBisherigeStundenwerte() { var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]); var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]), new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions); } [Fact] public void RecomputeTimes_LeitetUhrzeitJePhaseAusBeginnUndKumulierterDauerAb() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.StartTimeText = "12:00"; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 15; vm.AddPhaseCommand.Execute(null); vm.Phases[1].DurationMinutes = 10; Assert.Equal("ab 12:00", vm.Phases[0].ComputedTimeDisplay); Assert.Equal("ab 12:15", vm.Phases[1].ComputedTimeDisplay); Assert.Equal("25 Minuten gesamt", vm.TotalDurationDisplay); } [Fact] public void RecomputeTimes_OhneBeginnBleibtDieUhrzeitanzeigeLeer() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 15; Assert.Equal("", vm.Phases[0].ComputedTimeDisplay); } [Fact] public void Save_ErfordertThemaUndGueltigesDatum() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.Topic = ""; vm.DateText = "keinDatum"; vm.SaveCommand.Execute(null); Assert.Null(vm.Result); Assert.NotEqual("", vm.TopicError); Assert.NotEqual("", vm.DateTextError); } [Fact] public void Save_UngueltigesBeginnFormatWirdAbgelehnt() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.Topic = "Thema"; vm.DateText = "01.09.2025"; vm.StartTimeText = "nicht-uhrzeit"; vm.SaveCommand.Execute(null); Assert.Null(vm.Result); Assert.NotEqual("", vm.StartTimeTextError); } [Fact] public void Save_SetztUnitIdGroupIdUndUebernimmtPhasenUndBeginn() { var unitId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var lessons = new FakeLessons(); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null) { Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45", }; vm.AddPhaseCommand.Execute(null); vm.Phases[0].Name = "Einstieg"; vm.Phases[0].DurationMinutes = 10; vm.Phases[0].Activity = "Begrüßung"; vm.Phases[0].Material = "AB01"; vm.Phases[0].Shorthand = "L->AB01"; vm.SaveCommand.Execute(null); Assert.NotNull(vm.Result); Assert.Equal(unitId, vm.Result!.UnitId); Assert.Equal(groupId, vm.Result.GroupId); Assert.Equal(new TimeOnly(11, 45), vm.Result.StartTime); Assert.Single(vm.Result.Phases); Assert.Equal("Einstieg", vm.Result.Phases[0].Name); Assert.Equal(10, vm.Result.Phases[0].DurationMinutes); Assert.Equal("L->AB01", vm.Result.Phases[0].Shorthand); Assert.Single(lessons.GetByUnit(unitId)); } [Fact] public void HasHomeworkText_OhneHausaufgabe_IstFalse() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); Assert.False(vm.HasHomeworkText); vm.Homework = " "; Assert.False(vm.HasHomeworkText); vm.Homework = "S. 42"; Assert.True(vm.HasHomeworkText); } [Fact] public void Save_UebernimmtHomeworkCheckedUndDismissed() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.Topic = "Brechung"; vm.DateText = "01.09.2025"; vm.Homework = "S. 42"; vm.HomeworkChecked = true; vm.SaveCommand.Execute(null); Assert.True(vm.Result!.HomeworkChecked); Assert.False(vm.Result.HomeworkCheckDismissed); } [Fact] public void HomeworkChecked_UndHomeworkCheckDismissed_SchliessenSichGegenseitigAus() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.HomeworkChecked = true; Assert.False(vm.HomeworkCheckDismissed); vm.HomeworkCheckDismissed = true; Assert.False(vm.HomeworkChecked); } [Fact] public void Bearbeiten_UebernimmtGespeicherteHomeworkFlags() { var editing = new Lesson { Topic = "Alt", Date = new DateOnly(2025, 9, 1), Homework = "S. 42", HomeworkCheckDismissed = true, }; var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing); Assert.True(vm.HomeworkCheckDismissed); Assert.False(vm.HomeworkChecked); } [Fact] public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); var path = new AlternativeLessonPath { Name = "Kurzversion" }; vm.OnPickAlternativePath = _ => Task.FromResult(path); vm.AddPhaseCommand.Execute(null); vm.Phases[0].HasAlternativePath = true; Assert.True(vm.Phases[0].HasAlternativePath); Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); Assert.Equal(path.Id, vm.Phases[0].AlternativePathId); } [Fact] public void HasAlternativePath_AbbruchDerZuweisungLaesstEsUnangehakt() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); vm.OnPickAlternativePath = _ => Task.FromResult(null); // abgebrochen vm.AddPhaseCommand.Execute(null); vm.Phases[0].HasAlternativePath = true; Assert.False(vm.Phases[0].HasAlternativePath); Assert.Null(vm.Phases[0].AlternativePathId); } [Fact] public void HasAlternativePath_AbwaehlenEntferntZuordnungOhneDialogZuOeffnen() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); var path = new AlternativeLessonPath { Name = "Kurzversion" }; var dialogCalls = 0; vm.OnPickAlternativePath = _ => { dialogCalls++; return Task.FromResult(path); }; vm.AddPhaseCommand.Execute(null); vm.Phases[0].HasAlternativePath = true; // zuweisen -> 1 Dialogaufruf vm.Phases[0].HasAlternativePath = false; // abwählen -> kein weiterer Dialogaufruf Assert.Equal(1, dialogCalls); Assert.False(vm.Phases[0].HasAlternativePath); Assert.Equal("", vm.Phases[0].AlternativePathName); Assert.Null(vm.Phases[0].AlternativePathId); } [Fact] public void Save_UebernimmtZugewiesenenAlternativePfad() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); var path = new AlternativeLessonPath { Name = "Kurzversion" }; vm.OnPickAlternativePath = _ => Task.FromResult(path); vm.Topic = "Thema"; vm.DateText = "01.09.2025"; vm.AddPhaseCommand.Execute(null); vm.Phases[0].HasAlternativePath = true; vm.SaveCommand.Execute(null); Assert.Equal(path.Id, vm.Result!.Phases[0].AlternativePathId); } [Fact] public void AddPhaseInternal_LoestBestehendeZuordnungBeimBearbeitenAuf() { var path = new AlternativeLessonPath { Name = "Kurzversion" }; var alternativePaths = new FakeAlternativeLessonPaths([path]); var editing = new Lesson { Topic = "Thema", Date = new DateOnly(2025, 9, 1), Phases = [new LessonPhaseStep { Name = "Einstieg", AlternativePathId = path.Id }], }; var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths, new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing); Assert.True(vm.Phases[0].HasAlternativePath); Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName); } // ── Zeitbedarf-Rückmeldung (4.2.2 Nachtrag: Stundenraster) ─────────────────── [Fact] public void RecomputeTimeBudget_EinzelneStunde_ZeigtVerfuegbareZeitAusStundenraster() { var groupId = Guid.NewGuid(); var date = new DateOnly(2025, 9, 1); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }]); var vm = BuildVm(Guid.NewGuid(), groupId, periodSchedule: periodSchedule); vm.DateText = date.ToString("dd.MM.yyyy"); vm.LessonNumber = 3; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 40; Assert.True(vm.HasTimeBudgetInfo); Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel); } [Fact] public void RecomputeTimeBudget_Doppelstunde_AddiertBeidePerioden() { var groupId = Guid.NewGuid(); var date = new DateOnly(2025, 9, 1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 }); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 4 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([ new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }, new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) }, ]); var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule); vm.DateText = date.ToString("dd.MM.yyyy"); vm.LessonNumber = 3; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 84; Assert.True(vm.HasTimeBudgetInfo); Assert.Contains("84 von 90 Minuten geplant", vm.TimeBudgetLabel); } [Fact] public void RecomputeTimeBudget_FolgeperiodeGehoertAndererGruppe_WirdNichtMitgezaehlt() { var groupId = Guid.NewGuid(); var otherGroupId = Guid.NewGuid(); var date = new DateOnly(2025, 9, 1); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = date.DayOfWeek, PeriodNumber = 3 }); slots.Add(new TimetableSlot { GroupId = otherGroupId, Weekday = date.DayOfWeek, PeriodNumber = 4 }); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([ new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 45), End = new TimeOnly(10, 30) }, new PeriodTimeEntry { PeriodNumber = 4, Start = new TimeOnly(10, 30), End = new TimeOnly(11, 15) }, ]); var vm = BuildVm(Guid.NewGuid(), groupId, slots: slots, periodSchedule: periodSchedule); vm.DateText = date.ToString("dd.MM.yyyy"); vm.LessonNumber = 3; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 40; Assert.Contains("40 von 45 Minuten geplant", vm.TimeBudgetLabel); } [Fact] public void RecomputeTimeBudget_FarbeGruenImZielbereich() { var date = new DateOnly(2025, 9, 1); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); vm.DateText = date.ToString("dd.MM.yyyy"); vm.LessonNumber = 1; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 42; // 42/45 ≈ 93 % — Zielbereich Assert.Equal("#43A047", vm.TimeBudgetColorHex); } [Fact] public void RecomputeTimeBudget_FarbeDunkelrotBeiDeutlicherUeberplanung() { var date = new DateOnly(2025, 9, 1); var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]); var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); vm.DateText = date.ToString("dd.MM.yyyy"); vm.LessonNumber = 1; vm.AddPhaseCommand.Execute(null); vm.Phases[0].DurationMinutes = 60; // 60/45 ≈ 133 % — deutlich überplant Assert.Equal("#B71C1C", vm.TimeBudgetColorHex); } [Fact] public void LessonNumberGeaendert_UebernimmtBeginnAusStundenraster_WennNochKeinerEingetragen() { var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]); var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); vm.LessonNumber = 5; Assert.Equal("11:45", vm.StartTimeText); } [Fact] public void LessonNumberGeaendert_UeberschreibtBereitsEingetragenenBeginnNicht() { var periodSchedule = NewPeriodSchedule(); periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 5, Start = new TimeOnly(11, 45), End = new TimeOnly(12, 30) }]); var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), periodSchedule: periodSchedule); vm.StartTimeText = "09:00"; vm.LessonNumber = 5; Assert.Equal("09:00", vm.StartTimeText); } [Fact] public void RecomputeTimeBudget_OhneStundenrasterEintrag_KeineRueckmeldung() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // leeres PeriodScheduleService vm.DateText = "01.09.2025"; vm.LessonNumber = 1; vm.AddPhaseCommand.Execute(null); Assert.False(vm.HasTimeBudgetInfo); } // ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf // "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode // zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ──────────────────── [Fact] public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum() { var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText); } [Fact] public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor() { var unitId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag var lessons = new FakeLessons(); lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 }); var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 }); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null); // Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099. Assert.Equal("13.01.2099", vm.DateText); } [Fact] public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor() { var unitId = Guid.NewGuid(); var groupId = Guid.NewGuid(); var lessons = new FakeLessons(); lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 }); var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 }); var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]), slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null); var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy"); Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today)); Assert.Equal(weekday, suggested.DayOfWeek); } }