using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Sync; using LehrerApp.Sync.Models; using System.Linq; using Xunit; namespace LehrerApp.Desktop.Tests; public sealed class SettingsViewModelTests { private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, FakeSupervisionDuties? supervisionDuties = null, FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null, EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null, SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null, TrashViewModel? trashTab = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); return new SettingsViewModel( subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService(), appearance ?? TestSupport.BuildAppearanceSettingsService(), trashTab ?? TestSupport.BuildTrashViewModel()); } // ── Darstellung (12.4) ──────────────────────────────────────────────────────────────────── [Fact] public void SelectedTheme_SpiegeltDenGespeichertenStandBeimStartWider() { var appearance = TestSupport.BuildAppearanceSettingsService(); appearance.Save(AppTheme.Dark); var vm = BuildViewModel(appearance: appearance); Assert.Equal(AppTheme.Dark, vm.SelectedTheme); Assert.Equal("Dunkel", vm.SelectedThemeName); } [Fact] public void SelectedThemeName_Aendern_PersistiertUndBenachrichtigtDenCodeBehind() { var appearance = TestSupport.BuildAppearanceSettingsService(); var vm = BuildViewModel(appearance: appearance); AppTheme? notified = null; vm.OnThemeChanged = t => notified = t; vm.SelectedThemeName = "Dunkel"; Assert.Equal(AppTheme.Dark, notified); Assert.Equal(AppTheme.Dark, appearance.Load()); } // ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ──────────────────────────── // // Der "glückliche" Redeem-Pfad (gültiger Code + Datei + Bestätigung) endet in // AppBootstrapper.RestartApplication(), das Environment.Exit(0) aufruft - das darf ein Test // niemals auslösen (würde den Testlauf beenden). Deshalb sind hier nur die Zweige getestet, // die VOR diesem Aufruf zurückkehren; genau dasselbe Muster gilt bereits für // RedeemPairingCode, das aus demselben Grund ebenfalls keinen Erfolgstest hat. Der // eigentliche Kryptografie-/Datei-Roundtrip ist in SyncKeyRecoveryServiceTests abgedeckt. [Fact] public void SyncKeyWasRegenerated_SpiegeltDenBeimStartErkanntenStatusWider() { var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: true)); Assert.True(vm.SyncKeyWasRegenerated); } [Fact] public void SyncKeyWasRegenerated_NormalerStart_IstFalse() { var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: false)); Assert.False(vm.SyncKeyWasRegenerated); } [Fact] public async Task CreateRecoveryCode_SpeichernErfolgreich_ZeigtCodeUndUebergibtDateiinhalt() { var vm = BuildViewModel(); string? savedContent = null; vm.OnSaveRecoveryFile = content => { savedContent = content; return Task.FromResult(true); }; await vm.CreateRecoveryCodeCommand.ExecuteAsync(null); Assert.NotEmpty(vm.RecoveryCode); Assert.NotNull(savedContent); Assert.Contains("notieren", vm.RecoveryStatus); } [Fact] public async Task CreateRecoveryCode_AbbruchBeimSpeichern_ZeigtKeinenCode() { var vm = BuildViewModel(); vm.OnSaveRecoveryFile = _ => Task.FromResult(false); await vm.CreateRecoveryCodeCommand.ExecuteAsync(null); Assert.Empty(vm.RecoveryCode); Assert.Equal("Abgebrochen.", vm.RecoveryStatus); } [Fact] public async Task RedeemRecoveryCode_LeererCode_FragtNieNachEinerDatei() { var vm = BuildViewModel(); var filePickerCalled = false; vm.OnPickRecoveryFile = () => { filePickerCalled = true; return Task.FromResult(null); }; vm.RecoveryCodeInput = " "; await vm.RedeemRecoveryCodeCommand.ExecuteAsync(null); Assert.False(filePickerCalled); Assert.Equal("Bitte Code eingeben.", vm.RecoveryStatus); } [Fact] public async Task RedeemRecoveryCode_DateiauswahlAbgebrochen_TutNichts() { var vm = BuildViewModel(); vm.OnPickRecoveryFile = () => Task.FromResult(null); vm.RecoveryCodeInput = "IRGENDEIN-CODE"; var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); Assert.Null(exception); } [Fact] public async Task RedeemRecoveryCode_FalscherCode_ZeigtFehlerUndWirftNicht() { var recovery = TestSupport.BuildSyncKeyRecoveryService(); var vm = BuildViewModel(syncKeyRecovery: recovery); vm.OnPickRecoveryFile = () => Task.FromResult(recovery.CreateRecoveryFile("RICHTIGER-CODE")); vm.OnConfirmRecoveryRestore = () => Task.FromResult(true); vm.RecoveryCodeInput = "FALSCHER-CODE"; var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); Assert.Null(exception); Assert.Contains("passen nicht zusammen", vm.RecoveryStatus); } [Fact] public async Task RedeemRecoveryCode_BestaetigungAbgelehnt_TutNichts() { var recovery = TestSupport.BuildSyncKeyRecoveryService(); var vm = BuildViewModel(syncKeyRecovery: recovery); vm.OnPickRecoveryFile = () => Task.FromResult(recovery.CreateRecoveryFile("EIN-CODE")); vm.OnConfirmRecoveryRestore = () => Task.FromResult(false); vm.RecoveryCodeInput = "EIN-CODE"; var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null)); Assert.Null(exception); } [Fact] public void SyncConflicts_ZeigtUnreviewedKonflikteBeimLaden() { var queue = TestSupport.BuildEventQueue(); var conflict = new ConflictEntry { LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" }, RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" }, Resolution = "RemoteWon", }; queue.AddConflict(conflict); var vm = BuildViewModel(eventQueue: queue); var item = Assert.Single(vm.SyncConflicts); Assert.Equal(conflict.Id, item.Id); Assert.Contains("anderen Gerät", item.ResolutionDisplay); } [Fact] public void MarkConflictReviewed_EntferntKonfliktAusListeUndAusDerQueue() { var queue = TestSupport.BuildEventQueue(); var conflict = new ConflictEntry { LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" }, RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString(), Operation = "Save" }, Resolution = "LocalWon", }; queue.AddConflict(conflict); var vm = BuildViewModel(eventQueue: queue); var item = vm.SyncConflicts.Single(); vm.MarkConflictReviewedCommand.Execute(item); Assert.Empty(vm.SyncConflicts); Assert.Empty(queue.GetUnreviewed()); } [Fact] public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() { var holidays = new FakeSchoolHolidays(); var vm = BuildViewModel(holidays); vm.NewHolidayName = "Sommerferien"; vm.NewHolidayStartText = "01.07.2026"; vm.NewHolidayEndText = "10.08.2026"; vm.AddSchoolHolidayCommand.Execute(null); Assert.Single(holidays.GetAll()); Assert.Single(vm.SchoolHolidayEntries); } [Fact] public void AddSchoolHoliday_EndeVorBeginn_SetztFehlerUndSpeichertNicht() { var holidays = new FakeSchoolHolidays(); var vm = BuildViewModel(holidays); vm.NewHolidayName = "Ungültig"; vm.NewHolidayStartText = "10.08.2026"; vm.NewHolidayEndText = "01.07.2026"; vm.AddSchoolHolidayCommand.Execute(null); Assert.Empty(holidays.GetAll()); Assert.NotEqual("", vm.HolidayDateError); } [Fact] public void AddSchoolHoliday_FehlenderName_SetztFehlerUndSpeichertNicht() { var holidays = new FakeSchoolHolidays(); var vm = BuildViewModel(holidays); vm.NewHolidayStartText = "01.07.2026"; vm.NewHolidayEndText = "10.08.2026"; vm.AddSchoolHolidayCommand.Execute(null); Assert.Empty(holidays.GetAll()); Assert.NotEqual("", vm.HolidayNameError); } [Fact] public void RemoveSchoolHoliday_EntferntEintragAusRepositoryUndListe() { var holidays = new FakeSchoolHolidays(); var holiday = new SchoolHoliday { Name = "Herbstferien", StartDate = new DateOnly(2026, 10, 12), EndDate = new DateOnly(2026, 10, 24) }; holidays.Add(holiday); var vm = BuildViewModel(holidays); vm.RemoveSchoolHolidayCommand.Execute(vm.SchoolHolidayEntries[0]); Assert.Empty(holidays.GetAll()); Assert.Empty(vm.SchoolHolidayEntries); } [Fact] public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler() { var vm = BuildViewModel(); await vm.SaveSchoolLocationCommand.ExecuteAsync(null); Assert.NotEmpty(vm.SchoolNameError); Assert.NotEmpty(vm.SchoolStreetError); Assert.NotEmpty(vm.SchoolPostalCodeError); Assert.NotEmpty(vm.SchoolCityError); } [Fact] public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf() { var vm = BuildViewModel(); vm.SchoolName = "Testschule"; vm.SchoolStreet = "Schulweg 1"; vm.SchoolPostalCode = "1234"; vm.SchoolCity = "Teststadt"; await vm.SaveSchoolLocationCommand.ExecuteAsync(null); Assert.Contains("fünfstellige", vm.SchoolPostalCodeError); Assert.Empty(vm.SchoolLocationStatus); } [Fact] public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-state-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); var calendarSettings = new SchoolCalendarSettingsService(tempPath); var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.SelectedStateName = "Bayern"; Assert.Equal(GermanState.BY, calendarSettings.State); } [Fact] public void SavePeriodTimes_GueltigeEingabe_WirdPersistiert() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); var periodSchedule = new PeriodScheduleService(tempPath); var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; vm.SavePeriodTimesCommand.Execute(null); Assert.Equal("", vm.PeriodTimesError); Assert.Equal(45, periodSchedule.GetDurationMinutes(1)); } [Fact] public void SavePeriodTimes_EndeVorBeginn_SetztFehlerUndSpeichertNicht() { var tempPath = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"lehrerapp-settingsvm-periods-invalid-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempPath); var periodSchedule = new PeriodScheduleService(tempPath); var vm = new SettingsViewModel( new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(), new FakeSchemes(), new GradingService(), new BackupService(tempPath), new DatabaseEncryptionService(), new AppLockService(tempPath), new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; vm.SavePeriodTimesCommand.Execute(null); Assert.NotEqual("", vm.PeriodTimesError); Assert.Equal(0, periodSchedule.GetDurationMinutes(1)); } [Fact] public void AddSupervisionDuty_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() { var duties = new FakeSupervisionDuties(); var vm = BuildViewModel(supervisionDuties: duties); vm.NewDutyWeekdayName = "Montag"; vm.NewDutyAfterPeriod = 2; vm.NewDutyLocation = "Pausenhof"; vm.AddSupervisionDutyCommand.Execute(null); Assert.Single(duties.GetAll()); Assert.Single(vm.SupervisionDuties); Assert.Equal(DayOfWeek.Monday, duties.GetAll()[0].Weekday); Assert.Equal(2, duties.GetAll()[0].AfterPeriod); } [Fact] public void AddSupervisionDuty_FehlenderOrt_SetztFehlerUndSpeichertNicht() { var duties = new FakeSupervisionDuties(); var vm = BuildViewModel(supervisionDuties: duties); vm.NewDutyWeekdayName = "Montag"; vm.NewDutyAfterPeriod = 2; vm.AddSupervisionDutyCommand.Execute(null); Assert.Empty(duties.GetAll()); Assert.NotEqual("", vm.NewDutyError); } [Fact] public void AddSupervisionDuty_PauseBereitsBelegt_ZeigtFreundlicheFehlermeldung() { var duties = new FakeSupervisionDuties(); duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); var vm = BuildViewModel(supervisionDuties: duties); vm.NewDutyWeekdayName = "Montag"; vm.NewDutyAfterPeriod = 2; vm.NewDutyLocation = "Bibliothek"; vm.AddSupervisionDutyCommand.Execute(null); Assert.Single(duties.GetAll()); Assert.Equal("Für diese Pause ist bereits eine Aufsicht eingetragen.", vm.NewDutyError); } [Fact] public void RemoveSupervisionDuty_EntferntEintragAusRepositoryUndListe() { var duties = new FakeSupervisionDuties(); duties.Add(new SupervisionDuty { Weekday = DayOfWeek.Monday, AfterPeriod = 2, Location = "Pausenhof" }); var vm = BuildViewModel(supervisionDuties: duties); vm.RemoveSupervisionDutyCommand.Execute(vm.SupervisionDuties[0]); Assert.Empty(duties.GetAll()); Assert.Empty(vm.SupervisionDuties); } [Fact] public void Kompetenzbereiche_Umsortieren_PersistiertSortOrder() { var subject = new Subject { Name = "Biologie", ShortName = "Bio" }; var repository = new FakeCompetencyDomains(); repository.Add(new CompetencyDomain { SubjectId = subject.Id, GradeLevel = 10, Name = "Erster Bereich", SortOrder = 0, }); repository.Add(new CompetencyDomain { SubjectId = subject.Id, GradeLevel = 10, Name = "Zweiter Bereich", SortOrder = 1, }); var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository); vm.CatalogSubject = vm.Subjects.Single(); vm.Domains[1].MoveUpCommand.Execute(null); Assert.Equal(["Zweiter Bereich", "Erster Bereich"], vm.Domains.Select(x => x.Name)); var stored = repository.GetBySubjectAndGrade(subject.Id, 10); Assert.Equal(["Zweiter Bereich", "Erster Bereich"], stored.Select(x => x.Name)); Assert.Equal([0, 1], stored.Select(x => x.SortOrder)); } [Fact] public void Kompetenzen_Umsortieren_PersistiertSortOrder() { var repository = new FakeCompetencyDomains(); var domain = new CompetencyDomain { Name = "Bereich", Items = [ new CompetencyItem { Code = "A", Description = "Erste", SortOrder = 0 }, new CompetencyItem { Code = "B", Description = "Zweite", SortOrder = 1 }, ], }; repository.Add(domain); var vm = new DomainEditItem(domain, repository); vm.Items[1].MoveUpCommand.Execute(null); Assert.Equal(["B", "A"], vm.Items.Select(x => x.Code)); var stored = repository.GetById(domain.Id)!; Assert.Equal(["B", "A"], stored.Items.Select(x => x.Code)); Assert.Equal([0, 1], stored.Items.Select(x => x.SortOrder)); } [Fact] public void KatalogKopieren_BereitetSichereVorschauFuerZielstufeVor() { var subject = new Subject { Name = "Biologie", ShortName = "Bio" }; var repository = new FakeCompetencyDomains(); repository.Add(new CompetencyDomain { SubjectId = subject.Id, GradeLevel = 10, Name = "Erkenntnisgewinnung", Code = "EG", Items = [new CompetencyItem { Code = "EG1", Description = "Modelle verwenden" }], }); var vm = BuildViewModel(subjects: new FakeSubjects([subject]), competencyDomains: repository); vm.CatalogSubject = vm.Subjects.Single(); var preview = vm.PrepareCatalogCopy(11); Assert.NotNull(preview); Assert.Equal(11, preview.GradeLevel); Assert.Equal(1, preview.NewDomains); Assert.Equal(1, preview.NewCompetencies); vm.ApplyCatalogImport(preview, CompetencyCatalogImportMode.Merge, new HashSet()); var copied = repository.GetBySubjectAndGrade(subject.Id, 11); Assert.Single(copied); Assert.NotEqual(repository.GetBySubjectAndGrade(subject.Id, 10)[0].Id, copied[0].Id); Assert.Equal("EG1", copied[0].Items.Single().Code); } }