From fc2d7aea3e0f68e6e252529279b55a9d73354307 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 11:37:20 +0200 Subject: [PATCH] Baustein 9: Konflikt-Review-UI (Kapitel 10) Minimale Liste im Tab "Synchronisation" - Entitaet, Zeitpunkt, welche Seite ConflictResolver gewaehlt hat, mit "Gesehen"-Aktion. Kein Feld-Diff fuer v1: die Payloads sind clientseitig verschluesselt, ein Diff wuerde ohnehin nur rohes JSON zeigen. Neu EventQueue.MarkReviewed(id) - bisher gab es AddConflict/ GetUnreviewed/ConflictCount, aber keinen Weg, einen Konflikt als gesehen zu markieren. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Desktop.Tests/Fakes.cs | 9 +++ .../SettingsViewModelTests.cs | 54 ++++++++++++++-- .../ViewModels/Settings/SettingsViewModel.cs | 42 +++++++++++- .../Views/Settings/SettingsView.axaml | 26 ++++++++ LehrerApp.Sync.Tests/EventQueueTests.cs | 64 +++++++++++++++++++ LehrerApp.Sync/EventQueue.cs | 7 ++ TODO.md | 8 ++- 7 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 LehrerApp.Sync.Tests/EventQueueTests.cs diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 53f589c..449168b 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -1,6 +1,7 @@ using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; +using LehrerApp.Sync; namespace LehrerApp.Desktop.Tests; @@ -38,6 +39,14 @@ public static class TestSupport /// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe /// BuildAiPlanningService). public static SyncAuthService BuildSyncAuthService() => new(new HttpClient()); + + /// Eigenes Temp-Verzeichnis je Aufruf (echte, dateibasierte LiteDB wie bei EventQueue üblich). + public static EventQueue BuildEventQueue() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-eventqueue-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new EventQueue(Path.Combine(tempPath, "queue.db")); + } } public class FakeStudents(List all) : IStudentRepository diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 122d41c..ba1205e 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -2,6 +2,9 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Desktop.ViewModels.Settings; +using LehrerApp.Sync; +using LehrerApp.Sync.Models; +using System.Linq; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests { private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, FakeSupervisionDuties? supervisionDuties = null, - FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null) + FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null, + EventQueue? eventQueue = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState, // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -27,7 +31,47 @@ public sealed class SettingsViewModelTests holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), + eventQueue ?? TestSupport.BuildEventQueue()); + } + + [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] @@ -104,7 +148,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); vm.SelectedStateName = "Bayern"; @@ -127,7 +171,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); vm.PeriodTimes[0].StartText = "08:00"; vm.PeriodTimes[0].EndText = "08:45"; @@ -154,7 +198,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), - TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); + TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue()); vm.PeriodTimes[0].StartText = "08:45"; vm.PeriodTimes[0].EndText = "08:00"; diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 2ab0cab..0b32a44 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -6,6 +6,7 @@ using LehrerApp.Core.Services; using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; +using LehrerApp.Sync; using System.Collections.ObjectModel; using System.Globalization; using System.Text.Json; @@ -173,6 +174,8 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _syncIsLoggedIn; [ObservableProperty] private string _syncConnectionStatus = ""; + public ObservableCollection SyncConflicts { get; } = []; + // ── Konstruktor ─────────────────────────────────────────────────────────── private readonly ISchoolHolidayRepository _schoolHolidays; @@ -183,6 +186,7 @@ public partial class SettingsViewModel : ObservableObject private readonly AiPlanningService _aiPlanning; private readonly SyncSettingsService _syncSettings; private readonly SyncAuthService _syncAuth; + private readonly EventQueue _eventQueue; private readonly CompetencyCatalogImportService _catalogImport; public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, @@ -194,7 +198,7 @@ public partial class SettingsViewModel : ObservableObject SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, - SyncSettingsService syncSettings, SyncAuthService syncAuth) + SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue) { _subjects = subjects; _domainRepo = domainRepo; @@ -218,6 +222,7 @@ public partial class SettingsViewModel : ObservableObject _aiPlanning = aiPlanning; _syncSettings = syncSettings; _syncAuth = syncAuth; + _eventQueue = eventQueue; _catalogImport = new CompetencyCatalogImportService(domainRepo); LoadSubjects(); LoadShorthandCodes(); @@ -236,6 +241,7 @@ public partial class SettingsViewModel : ObservableObject LoadLetterTemplates(); LoadAiSettings(); LoadSyncSettings(); + LoadSyncConflicts(); } // ── Word-Briefvorlagen: Import und Validierung ────────────────────────── @@ -436,6 +442,27 @@ public partial class SettingsViewModel : ObservableObject AppBootstrapper.RestartApplication(); } + // ── Synchronisation: Konflikte ──────────────────────────────────────────── + // + // Zeigt, was ConflictResolver bereits entschieden hat (welche Seite gewonnen hat) — kein + // Feld-Diff für v1, die Payloads sind clientseitig verschlüsselt und würden hier ohnehin nur + // rohes JSON zeigen. Minimal: Entität, Zeitpunkt, Ergebnis, "gesehen"-Aktion. + + private void LoadSyncConflicts() + { + SyncConflicts.Clear(); + foreach (var c in _eventQueue.GetUnreviewed().OrderByDescending(c => c.DetectedAt)) + SyncConflicts.Add(new SyncConflictListItem(c)); + } + + [RelayCommand] + private void MarkConflictReviewed(SyncConflictListItem? item) + { + if (item is null) return; + _eventQueue.MarkReviewed(item.Id); + SyncConflicts.Remove(item); + } + // ── Stundenraster: Laden / Speichern ───────────────────────────────────── private void LoadPeriodTimes() @@ -1295,6 +1322,19 @@ public class ExpiredDocumentItem(Documentation d, string studentName) public string CreatedAtDisplay { get; } = d.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy"); } +public class SyncConflictListItem(ConflictEntry c) +{ + public Guid Id { get; } = c.Id; + public string EntityDisplay { get; } = $"{c.RemoteEvent.EntityType} ({c.RemoteEvent.EntityId[..Math.Min(8, c.RemoteEvent.EntityId.Length)]}…)"; + public string DetectedAtDisplay { get; } = c.DetectedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"); + public string ResolutionDisplay { get; } = c.Resolution switch + { + "LocalWon" => "Lokale Änderung übernommen (dieses Gerät)", + "RemoteWon" => "Änderung vom anderen Gerät übernommen", + _ => c.Resolution, + }; +} + public class SchoolHolidayItem(SchoolHoliday h) { public Guid Id { get; } = h.Id; diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index c722e8b..a26def9 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -806,6 +806,32 @@ + + + + + + + + + + + + + +