diff --git a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs index 4c31e12..caf940d 100644 --- a/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SyncStatusViewModelTests.cs @@ -1,7 +1,10 @@ +using System.Net; +using System.Net.Http.Json; using LehrerApp.Data; using LehrerApp.Desktop.ViewModels; using LehrerApp.Sync; using LehrerApp.Sync.Crypto; +using LehrerApp.Sync.Models; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -33,16 +36,82 @@ public sealed class SyncStatusViewModelTests Assert.Equal("Kein Server konfiguriert", vm.StatusText); } + /// Regression (TODO 10.1.11): eine per Sync-Pull eingehende Änderung erschien im UI erst nach + /// manuellem Neuladen (z.B. Tab-Wechsel), weil EventApplier absichtlich an jedem ViewModel + /// vorbei direkt auf die LiteDB schreibt. SyncStatusViewModel.DataChanged ist der Hook, über + /// den MainWindowViewModel die sichtbare Seite danach neu lädt - hier wird nur geprüft, dass + /// das Ereignis von SyncEngine bis hierher tatsächlich durchgereicht wird. + [Fact] + public async Task DataChanged_PullMitEreignissen_WirdVonEngineDurchgereicht() + { + using var temp = new TempSyncEngine(new PullEventStubHandler()); + var vm = new SyncStatusViewModel(temp.Engine); + var fired = false; + vm.DataChanged += () => fired = true; + + await temp.Engine.SyncNowAsync(); + + Assert.True(fired); + } + + [Fact] + public async Task DataChanged_PullOhneEreignisse_FeuertNicht() + { + using var temp = new TempSyncEngine(new EmptyPullStubHandler()); + var vm = new SyncStatusViewModel(temp.Engine); + var fired = false; + vm.DataChanged += () => fired = true; + + await temp.Engine.SyncNowAsync(); + + Assert.False(fired); + } + + private sealed class PullEventStubHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath == "/api/sync/pull") + { + var evt = new SyncEvent + { + DeviceId = "other-device", DeviceType = DeviceType.Desktop, + EntityType = "Student", EntityId = Guid.NewGuid().ToString(), + Operation = "Save", Payload = "irrelevant-fuer-diesen-test", SequenceNr = 1, + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse { Events = [evt], ServerSequenceNr = 1 }) }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PushResponse()) }); + } + } + + private sealed class EmptyPullStubHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(request.RequestUri!.AbsolutePath == "/api/sync/pull" + ? new PullResponse() + : (object)new PushResponse()), + }); + } + private sealed class TempSyncEngine : IDisposable { private readonly string _queuePath = Path.Combine( Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db"); private readonly LiteDbContext _db = new(new MemoryStream()); - private readonly HttpClient _http = new(); + private readonly HttpClient _http; public SyncEngine Engine { get; } - public TempSyncEngine() + public TempSyncEngine(HttpMessageHandler? handler = null) { + _http = handler is null ? new HttpClient() : new HttpClient(handler); + _http.BaseAddress = new Uri("https://example.invalid"); var queue = new EventQueue(_queuePath); var key = SyncCrypto.GenerateKey(); Engine = new SyncEngine( diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index b816c0a..bdd2e4f 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Services; @@ -43,6 +44,39 @@ public partial class MainWindowViewModel : ObservableObject CurrentSchoolYear = sy.CurrentSchoolYear(); CurrentPage = dashboard; AppLock.ApplyConfig(); + SyncStatus.DataChanged += OnSyncDataChanged; + } + + // EventApplier schreibt bei eingehenden Sync-Ereignissen absichtlich direkt auf die rohe + // LiteDB-Collection, an jedem ViewModel vorbei (Ping-Pong-Vermeidung, siehe EventApplier- + // Klassenkommentar) - ohne diesen Hook blieb die gerade sichtbare Seite bis zum nächsten + // manuellen Neuladen (z.B. Tab-Wechsel) auf dem alten Stand (TODO 10.1.11). Lädt bewusst nur + // die aktuell sichtbare Seite über ihren eigenen, längst vorhandenen Lade-Einstieg neu - keine + // neue Lade-Logik, keine Navigation weg von einer offenen Detailansicht. SyncEngine.DataChanged + // feuert aus dem Sync-Timer-Hintergrundthread - daher der Dispatcher-Sprung zurück auf den + // UI-Thread (gleiches Muster wie NotificationService.Show). + private void OnSyncDataChanged() + { + if (Dispatcher.UIThread.CheckAccess()) RefreshCurrentPage(); + else Dispatcher.UIThread.Post(RefreshCurrentPage); + } + + private void RefreshCurrentPage() + { + switch (CurrentPage) + { + case DashboardViewModel vm: vm.RefreshCommand.Execute(null); break; + case GroupListViewModel vm: vm.RefreshCommand.Execute(null); break; + case StudentListViewModel vm: vm.LoadStudents(); break; + case TimetableViewModel vm: vm.Load(); break; + case WorkloadViewModel vm: + vm.Tasks.Load(); vm.TimeTracking.Load(); vm.Evaluation.Load(); break; + case GroupDetailViewModel { Group: { } group } vm: vm.LoadGroup(group.Id); break; + // Inline-Bearbeitung (IsEditing) nicht überschreiben - anders als die Gruppenansicht + // laufen Namens-/Geschlechtsänderungen hier nicht über einen Dialog. + case StudentDetailViewModel { Student: { } student, IsEditing: false } vm: + vm.LoadStudent(student.Id); break; + } } partial void OnActiveNavItemChanged(NavItem value) diff --git a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs index 3e0c475..d6e4528 100644 --- a/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/SyncStatusViewModel.cs @@ -15,6 +15,12 @@ public partial class SyncStatusViewModel : ObservableObject [ObservableProperty] private bool _isServerConfigured; [ObservableProperty] private int _pendingCount; + /// Feuert, wenn ein Sync tatsächlich Daten angewendet hat (siehe SyncEngine. + /// DataChanged, TODO 10.1.11) - MainWindowViewModel nutzt das, um die gerade sichtbare Seite + /// neu zu laden, da EventApplier absichtlich an jedem ViewModel vorbei direkt auf die LiteDB + /// schreibt. + public event Action? DataChanged; + public SyncStatusViewModel(SyncEngine? engine) { _engine = engine; @@ -22,6 +28,7 @@ public partial class SyncStatusViewModel : ObservableObject if (_engine is not null) { _engine.StatusChanged += OnStatus; + _engine.DataChanged += () => DataChanged?.Invoke(); // SyncEngine feuert sein erstes StatusChanged bereits im eigenen Konstruktor // (UpdateStatus) - der läuft aber schon, während wir hier noch in GetService() // stecken, also VOR dem obigen Abonnieren. Ohne diesen Nachhol-Aufruf bliebe StatusText diff --git a/LehrerApp.Sync.Tests/SyncEngineTests.cs b/LehrerApp.Sync.Tests/SyncEngineTests.cs index 7c63c66..d1306ac 100644 --- a/LehrerApp.Sync.Tests/SyncEngineTests.cs +++ b/LehrerApp.Sync.Tests/SyncEngineTests.cs @@ -67,6 +67,55 @@ public sealed class SyncEngineTests Assert.Equal(2, temp.Queue.GetLastServerSeq()); } + // ── DataChanged: Hook für den Desktop-Client, die sichtbare Seite neu zu laden (TODO 10.1.11) ─ + + [Fact] + public async Task DataChanged_PullMitEreignissen_Feuert() + { + using var temp = new TempEventQueue(); + var goodStudent = new Student { FirstName = "Anna", LastName = "Beispiel" }; + var evt = new SyncEvent + { + DeviceId = "other-device", DeviceType = DeviceType.Desktop, + EntityType = nameof(Student), EntityId = goodStudent.Id.ToString(), + Operation = "Save", Payload = SyncCrypto.EncryptObject(goodStudent, Key), SequenceNr = 1, + }; + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri!.AbsolutePath == "/api/sync/pull") + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PullResponse { Events = [evt], ServerSequenceNr = 1 }) }; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = JsonContent.Create(new PushResponse()) }; + }); + var engine = MakeEngine(temp, handler); + var fired = false; + engine.DataChanged += () => fired = true; + + await engine.SyncNowAsync(); + + Assert.True(fired); + } + + [Fact] + public async Task DataChanged_PullOhneEreignisse_FeuertNicht() + { + using var temp = new TempEventQueue(); + var handler = new FakeHttpMessageHandler(req => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(req.RequestUri!.AbsolutePath == "/api/sync/pull" + ? new PullResponse() + : (object)new PushResponse()), + }); + var engine = MakeEngine(temp, handler); + var fired = false; + engine.DataChanged += () => fired = true; + + await engine.SyncNowAsync(); + + Assert.False(fired); + } + /// Regression: PushAsync setzte den lokalen Pull-Cursor bisher direkt aus PushResponse. /// ServerSequenceNr - dem GLOBALEN Zähler über alle Geräte NACH diesem Push, nicht dem Stand, /// den DIESES Gerät tatsächlich per Pull erhalten hat. Hatte der Server zum Push-Zeitpunkt @@ -239,6 +288,8 @@ public sealed class SyncEngineTests }); var applier = new EventApplier(db, Key, versions: temp.Queue); var engine = MakeEngine(temp, handler, applier); + var dataChanged = false; + engine.DataChanged += () => dataChanged = true; await engine.SyncNowAsync(); @@ -246,6 +297,9 @@ public sealed class SyncEngineTests Assert.Equal("RemoteWon", conflict.Resolution); Assert.NotNull(db.Students.FindById(entityId)); Assert.Equal(0, temp.Queue.PendingCount()); + // DataChanged muss auch bei einem RemoteWon-Konflikt feuern - dabei wird lokal genauso + // Daten angewendet wie bei einem regulären Pull (TODO 10.1.11). + Assert.True(dataChanged); Assert.Equal(99, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString())); } diff --git a/LehrerApp.Sync/SyncEngine.cs b/LehrerApp.Sync/SyncEngine.cs index e322a87..11e04d4 100644 --- a/LehrerApp.Sync/SyncEngine.cs +++ b/LehrerApp.Sync/SyncEngine.cs @@ -22,6 +22,13 @@ public class SyncEngine : IDisposable public SyncStatus Status { get; private set; } = new(); public event Action? StatusChanged; + /// + /// Feuert, wenn ein Pull tatsächlich Ereignisse angewendet hat (siehe TODO 10.1.11) - der + /// Desktop-Client abonniert das, um die gerade sichtbare Seite neu zu laden, da + /// EventApplier absichtlich an jedem ViewModel vorbei direkt auf die LiteDB schreibt (siehe + /// EventApplier-Klassenkommentar). + /// + public event Action? DataChanged; public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier, AttachmentSyncer attachments, HttpClient http, SyncConfig config, AppLogger? logger = null) @@ -200,6 +207,7 @@ public class SyncEngine : IDisposable { await _applier.ApplyAsync(remote); _queue.Acknowledge([local.EventId]); + DataChanged?.Invoke(); } // LocalWon: Ereignis bleibt unbestätigt in der Queue - der nächste PushAsync-Lauf // versucht es erneut, jetzt mit dem soeben aktualisierten BasedOnServerSeq. @@ -231,6 +239,7 @@ public class SyncEngine : IDisposable if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt); } _queue.SetLastServerSeq(resp.ServerSequenceNr); + DataChanged?.Invoke(); return (resp.Events.Count, conflicts); } diff --git a/TODO.md b/TODO.md index 7edf390..0434a88 100644 --- a/TODO.md +++ b/TODO.md @@ -1669,6 +1669,42 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba setzt `EventQueue.SetLastServerSeq(0)` und stößt danach `SyncEngine.SyncNowAsync()` an — sicher wiederholbar, da `EventApplier` jedes Ereignis idempotent per Upsert/Delete-by-Id anwendet. +- [x] **10.1.11** Offene ViewModels aktualisierten sich nicht automatisch, wenn per Sync + eingehende Ereignisse angewendet werden (Nutzer-Bug-Report: eine per Sync empfangene neue + Stunde erschien im Stundenplan/Unterrichtsplanung erst nach Schließen und erneutem Öffnen + des Tabs — kein Datenverlust, nur ein reines Anzeigeproblem). + + **Ursache:** `EventApplier.ApplyAsync` schreibt bewusst immer direkt auf die rohe LiteDB- + Collection (`db.Students.Upsert(...)` etc.), nie über eine Repository-Save/Delete-Methode — + sonst würde der `OnChange`-Hook erneut feuern und die gerade angewendete Änderung als neues + *ausgehendes* Ereignis re-enqueuen (Sync-Ping-Pong, siehe Kommentar am Klassenkopf). Genau + dieser direkte Collection-Zugriff bedeutet aber auch, dass kein ViewModel benachrichtigt + wird — viele ViewModels haben zwar bereits ein `Refresh()`/`Load...()`, das man manuell + auslösen kann (z.B. `PlanningViewModels.LoadLessons`, `GroupViewModels.LoadGroups`), aber + nichts ruft das automatisch auf, wenn `SyncEngine.PullAsync` im Hintergrund neue Daten + anwendet. + + **Umsetzung** (zunächst für v1 zurückgestellt, dann doch umgesetzt, da leicht möglich): neues + `SyncEngine.DataChanged`-Ereignis, gefeuert nach jedem Pull mit mindestens einem Ereignis + sowie nach einer RemoteWon-Konfliktauflösung (dort wird ebenso lokal Daten angewendet). + `SyncStatusViewModel` reicht es unverändert durch (bereits als DI-Singleton überall + verfügbar, kein neuer Dienst nötig). `MainWindowViewModel` abonniert es und lädt darauf + **ausschließlich die gerade sichtbare Seite** (`CurrentPage`) über ihren eigenen, längst + vorhandenen Lade-Einstieg neu (Type-Switch: `DashboardViewModel`/`GroupListViewModel`/ + `StudentListViewModel`/`TimetableViewModel`/`WorkloadViewModel` per `RefreshCommand`/ + `Load()`, `GroupDetailViewModel` per `LoadGroup(Group.Id)`, `StudentDetailViewModel` per + `LoadStudent(Student.Id)` — letzteres nur wenn `!IsEditing`, um eine laufende + Inline-Bearbeitung von Name/Geschlecht nicht zu überschreiben; `GroupDetailViewModel` hat + kein Äquivalent, da dort ausschließlich über Dialoge bearbeitet wird). Bewusst **keine** + Navigation weg von einer offenen Detailansicht und keine neue Lade-Logik — nur die fehlende + Verdrahtung zwischen Sync-Empfang und den längst vorhandenen Refresh-Methoden. `SyncEngine. + DataChanged` feuert aus dem Sync-Timer-Hintergrundthread; `MainWindowViewModel` springt + deshalb wie `NotificationService.Show` per `Dispatcher.UIThread` zurück auf den UI-Thread. + Neue Tests: `SyncEngineTests` (`DataChanged` bei Pull mit/ohne Ereignisse, bei RemoteWon), + `SyncStatusViewModelTests` (Durchreichen). `MainWindowViewModel` selbst ist mangels + Testinfrastruktur für seine vielen ViewModel-Abhängigkeiten nicht direkt getestet — die + Refresh-Aufrufe delegieren aber ausschließlich an bereits anderswo getestete + Lade-Methoden der einzelnen ViewModels. ### 10.2 Server - [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern