From 86e114580fdcc85057a7d35514d5d29c682ed4cd Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 7 Sep 2026 17:09:38 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Sync=20Log=20und=20verwaise=20Eintr?= =?UTF-8?q?=C3=A4ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClassTeacherViewModelsTests.cs | 19 ++++++++++ .../DocumentationDialogViewModelTests.cs | 14 ++++++++ .../SettingsViewModelTests.cs | 19 ++++++++++ .../UntisSyncServiceTests.cs | 6 ++-- .../Services/UntisSyncService.cs | 21 +++++++++-- .../ClassTeacherDetailsViewModel.cs | 35 ++++++++++++++++--- .../Settings/SettingsViewModel.Sync.cs | 7 ++++ .../Students/DocumentationViewModels.cs | 5 +++ .../ClassTeacherRegisterView.axaml | 1 + .../Views/Settings/SettingsView.axaml | 10 ++++-- LehrerApp.Sync.Tests/EventQueueTests.cs | 19 ++++++++++ LehrerApp.Sync/EventQueue.cs | 1 + 12 files changed, 145 insertions(+), 12 deletions(-) diff --git a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs index 25db686..772cd1e 100644 --- a/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs +++ b/LehrerApp.Desktop.Tests/ClassTeacherViewModelsTests.cs @@ -905,6 +905,25 @@ public sealed class ClassTeacherViewModelsTests Assert.Equal(["Ada"], result.Select(d => d.Title)); } + [Fact] + public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname() + { + var rosterMatches = new List<(Guid StudentId, string DisplayName)> + { + (Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"), + }; + var orphan = new Documentation + { + StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7), + Title = "Vorfall", Participants = ["Karim", "Saleh"], + }; + + var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches, + new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim"); + + Assert.Same(orphan, Assert.Single(result)); + } + // ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ─────────────────────────── [Fact] diff --git a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs index faf0cc1..7f5d394 100644 --- a/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DocumentationDialogViewModelTests.cs @@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests Assert.NotEqual("", vm.StudentError); } + [Fact] + public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag() + { + var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage()) + { + Title = "Vorfall", TypeName = "Vorkommnis", + }; + + vm.SaveCommand.Execute(null); + + Assert.Null(vm.Result); + Assert.Contains("noch nicht geladen", vm.StudentError); + } + [Fact] public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe() { diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 1241703..daa4da2 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -219,6 +219,25 @@ public sealed class SettingsViewModelTests Assert.Empty(queue.GetUnreviewed()); } + [Fact] + public void ClearSyncConflicts_LeertProtokollUndQueue() + { + var queue = TestSupport.BuildEventQueue(); + foreach (var resolution in new[] { "LocalWon", "RemoteWon" }) + queue.AddConflict(new ConflictEntry + { + LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() }, + RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() }, + Resolution = resolution, + }); + var vm = BuildViewModel(eventQueue: queue); + + vm.ClearSyncConflictsCommand.Execute(null); + + Assert.Empty(vm.SyncConflicts); + Assert.Empty(queue.GetUnreviewed()); + } + [Fact] public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt() { diff --git a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs index 7bef6ab..b0ebc33 100644 --- a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs +++ b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs @@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests } [Fact] - public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung() + public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung() { var mappings = new FakeUntisSlotMappings(); mappings.Add(new UntisSlotMapping @@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests // Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId. var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); - Assert.Equal(1, second.SubstitutionCount); + Assert.Equal(0, second.SubstitutionCount); Assert.Single(substitutions.GetAll()); } @@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests // Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen // zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid). var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); - Assert.Equal(1, second.SubstitutionCount); + Assert.Equal(0, second.SubstitutionCount); Assert.Single(substitutions.GetAll()); } diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs index a4818f4..cbdbd36 100644 --- a/LehrerApp.Desktop/Services/UntisSyncService.cs +++ b/LehrerApp.Desktop/Services/UntisSyncService.cs @@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today, existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today)); + var savedSubstitutionCount = 0; foreach (var candidate in diffResult.SubstitutionsToSave) { var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null; - if (existing is not null) candidate.Id = existing.Id; + // Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten. + // Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein + // Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten. + if (existing is not null) + { + candidate.Id = existing.Id; + if (SubstitutionContentEquals(existing, candidate)) continue; + } _substitutions.Save(candidate); + savedSubstitutionCount++; } // Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe // UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser @@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot); foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id); - return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count); + return new UntisPollResult(events.Count, savedSubstitutionCount); } + private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) => + left.Date == right.Date && left.Kind == right.Kind && + left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod && + left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod && + left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId && + left.GroupLabel == right.GroupLabel && left.Description == right.Description && + left.Notes == right.Notes && left.ExternalId == right.ExternalId; + // Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster // hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive // "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall diff --git a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs index ad9854e..0b4fc45 100644 --- a/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/ClassTeacher/ClassTeacherDetailsViewModel.cs @@ -199,6 +199,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries; public bool HasCategoryAggregates => CategoryAggregates.Count > 0; public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0; + public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0; /// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen () /// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup. public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation; @@ -431,7 +432,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject _loadedStart, _loadedEnd, StudentFilter); foreach (var d in entries) { - var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName; + var name = DocumentationStudentDisplay(d, _rosterMatches); OwnDocumentationEntries.Add(new DocumentationItem(d, name)); } OwnDocumentationCount = OwnDocumentationEntries.Count; @@ -452,7 +453,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject { var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet(); return all - .Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end) + .Where(d => !d.IsDeleted && + (matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) && + d.Date >= start && d.Date <= end) .Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter)) .OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date) .ToList(); @@ -462,14 +465,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter) { if (string.IsNullOrWhiteSpace(studentFilter)) return true; + if (IsUnassignedClassDocumentation(d)) + return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter)); var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName; - return name is not null && UntisNameMatching.NamesMatch(name, studentFilter); + return name is not null && NameContainsSearchTerm(name, studentFilter); + } + + private static bool IsUnassignedClassDocumentation(Documentation d) => + d.StudentId == Guid.Empty && d.GroupId is null; + + private static bool NameContainsSearchTerm(string name, string search) + { + if (UntisNameMatching.NamesMatch(name, search)) return true; + var searchKey = UntisNameMatching.NameKey(search); + return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey); + } + + private static string DocumentationStudentDisplay(Documentation d, + IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches) + { + if (d.StudentId != Guid.Empty) + return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName + ?? "Unbekannter Schülerbezug"; + return d.Participants.Count > 0 + ? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}" + : "Ohne Schülerbezug"; } [RelayCommand] private async Task AddOwnDocumentation() { - if (OnEditOwnDocumentation is null) return; + if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return; var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList(); var result = await OnEditOwnDocumentation(options, null); if (result is null) return; @@ -553,6 +579,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject OnPropertyChanged(nameof(HasCategoryAggregates)); OnPropertyChanged(nameof(ShowCategoryAggregates)); OnPropertyChanged(nameof(HasOwnDocumentationEntries)); + OnPropertyChanged(nameof(CanAddOwnDocumentation)); OnPropertyChanged(nameof(UntisCriticalCount)); } } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs index a234b6d..660761e 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.Sync.cs @@ -229,6 +229,13 @@ public partial class SettingsViewModel _eventQueue.MarkReviewed(item.Id); SyncConflicts.Remove(item); } + + [RelayCommand] + private void ClearSyncConflicts() + { + _eventQueue.ClearConflicts(); + SyncConflicts.Clear(); + } } public class SyncConflictListItem(ConflictEntry c) diff --git a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs index 047391e..051897f 100644 --- a/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Students/DocumentationViewModels.cs @@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject var valid = true; if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; } + if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null) + { + StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen."; + valid = false; + } if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; } diff --git a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml index 5a25f53..bf78571 100644 --- a/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml +++ b/LehrerApp.Desktop/Views/ClassTeacher/ClassTeacherRegisterView.axaml @@ -175,6 +175,7 @@