feat: sichtbare Seite nach eingehendem Sync automatisch neu laden
EventApplier schreibt eingehende Sync-Ereignisse absichtlich direkt auf die rohe LiteDB-Collection (Ping-Pong-Vermeidung), benachrichtigt dabei aber kein ViewModel - eine per Sync empfangene neue Stunde erschien bisher erst nach manuellem Neuladen (z.B. Tab-Wechsel). Neues SyncEngine.DataChanged-Ereignis, gefeuert nach jedem Pull mit Ereignissen sowie bei einer RemoteWon-Konfliktauflösung; SyncStatusViewModel reicht es durch. MainWindowViewModel abonniert es und lädt ausschließlich die gerade sichtbare Seite über ihren eigenen, längst vorhandenen Lade-Einstieg neu - keine Navigation weg von offenen Detailansichten, keine neue Lade-Logik, und eine laufende Inline-Bearbeitung (Schülerdetail) wird nicht überschrieben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
using LehrerApp.Data;
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Crypto;
|
using LehrerApp.Sync.Crypto;
|
||||||
|
using LehrerApp.Sync.Models;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -33,16 +36,82 @@ public sealed class SyncStatusViewModelTests
|
|||||||
Assert.Equal("Kein Server konfiguriert", vm.StatusText);
|
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<HttpResponseMessage> 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<HttpResponseMessage> 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 sealed class TempSyncEngine : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _queuePath = Path.Combine(
|
private readonly string _queuePath = Path.Combine(
|
||||||
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
|
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
|
||||||
private readonly LiteDbContext _db = new(new MemoryStream());
|
private readonly LiteDbContext _db = new(new MemoryStream());
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http;
|
||||||
public SyncEngine Engine { get; }
|
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 queue = new EventQueue(_queuePath);
|
||||||
var key = SyncCrypto.GenerateKey();
|
var key = SyncCrypto.GenerateKey();
|
||||||
Engine = new SyncEngine(
|
Engine = new SyncEngine(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
@@ -43,6 +44,39 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
CurrentSchoolYear = sy.CurrentSchoolYear();
|
CurrentSchoolYear = sy.CurrentSchoolYear();
|
||||||
CurrentPage = dashboard;
|
CurrentPage = dashboard;
|
||||||
AppLock.ApplyConfig();
|
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)
|
partial void OnActiveNavItemChanged(NavItem value)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public partial class SyncStatusViewModel : ObservableObject
|
|||||||
[ObservableProperty] private bool _isServerConfigured;
|
[ObservableProperty] private bool _isServerConfigured;
|
||||||
[ObservableProperty] private int _pendingCount;
|
[ObservableProperty] private int _pendingCount;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public event Action? DataChanged;
|
||||||
|
|
||||||
public SyncStatusViewModel(SyncEngine? engine)
|
public SyncStatusViewModel(SyncEngine? engine)
|
||||||
{
|
{
|
||||||
_engine = engine;
|
_engine = engine;
|
||||||
@@ -22,6 +28,7 @@ public partial class SyncStatusViewModel : ObservableObject
|
|||||||
if (_engine is not null)
|
if (_engine is not null)
|
||||||
{
|
{
|
||||||
_engine.StatusChanged += OnStatus;
|
_engine.StatusChanged += OnStatus;
|
||||||
|
_engine.DataChanged += () => DataChanged?.Invoke();
|
||||||
// SyncEngine feuert sein erstes StatusChanged bereits im eigenen Konstruktor
|
// SyncEngine feuert sein erstes StatusChanged bereits im eigenen Konstruktor
|
||||||
// (UpdateStatus) - der läuft aber schon, während wir hier noch in GetService<SyncEngine>()
|
// (UpdateStatus) - der läuft aber schon, während wir hier noch in GetService<SyncEngine>()
|
||||||
// stecken, also VOR dem obigen Abonnieren. Ohne diesen Nachhol-Aufruf bliebe StatusText
|
// stecken, also VOR dem obigen Abonnieren. Ohne diesen Nachhol-Aufruf bliebe StatusText
|
||||||
|
|||||||
@@ -67,6 +67,55 @@ public sealed class SyncEngineTests
|
|||||||
Assert.Equal(2, temp.Queue.GetLastServerSeq());
|
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.
|
/// 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,
|
/// 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
|
/// 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 applier = new EventApplier(db, Key, versions: temp.Queue);
|
||||||
var engine = MakeEngine(temp, handler, applier);
|
var engine = MakeEngine(temp, handler, applier);
|
||||||
|
var dataChanged = false;
|
||||||
|
engine.DataChanged += () => dataChanged = true;
|
||||||
|
|
||||||
await engine.SyncNowAsync();
|
await engine.SyncNowAsync();
|
||||||
|
|
||||||
@@ -246,6 +297,9 @@ public sealed class SyncEngineTests
|
|||||||
Assert.Equal("RemoteWon", conflict.Resolution);
|
Assert.Equal("RemoteWon", conflict.Resolution);
|
||||||
Assert.NotNull(db.Students.FindById(entityId));
|
Assert.NotNull(db.Students.FindById(entityId));
|
||||||
Assert.Equal(0, temp.Queue.PendingCount());
|
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()));
|
Assert.Equal(99, temp.Queue.GetKnownServerSeq(nameof(Student), entityId.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ public class SyncEngine : IDisposable
|
|||||||
|
|
||||||
public SyncStatus Status { get; private set; } = new();
|
public SyncStatus Status { get; private set; } = new();
|
||||||
public event Action<SyncStatus>? StatusChanged;
|
public event Action<SyncStatus>? StatusChanged;
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public event Action? DataChanged;
|
||||||
|
|
||||||
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
public SyncEngine(EventQueue queue, ConflictResolver resolver, EventApplier applier,
|
||||||
AttachmentSyncer attachments, HttpClient http, SyncConfig config, AppLogger? logger = null)
|
AttachmentSyncer attachments, HttpClient http, SyncConfig config, AppLogger? logger = null)
|
||||||
@@ -200,6 +207,7 @@ public class SyncEngine : IDisposable
|
|||||||
{
|
{
|
||||||
await _applier.ApplyAsync(remote);
|
await _applier.ApplyAsync(remote);
|
||||||
_queue.Acknowledge([local.EventId]);
|
_queue.Acknowledge([local.EventId]);
|
||||||
|
DataChanged?.Invoke();
|
||||||
}
|
}
|
||||||
// LocalWon: Ereignis bleibt unbestätigt in der Queue - der nächste PushAsync-Lauf
|
// LocalWon: Ereignis bleibt unbestätigt in der Queue - der nächste PushAsync-Lauf
|
||||||
// versucht es erneut, jetzt mit dem soeben aktualisierten BasedOnServerSeq.
|
// 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);
|
if (c.Resolution == "RemoteWon") await _applier.ApplyAsync(evt);
|
||||||
}
|
}
|
||||||
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
_queue.SetLastServerSeq(resp.ServerSequenceNr);
|
||||||
|
DataChanged?.Invoke();
|
||||||
return (resp.Events.Count, conflicts);
|
return (resp.Events.Count, conflicts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 —
|
setzt `EventQueue.SetLastServerSeq(0)` und stößt danach `SyncEngine.SyncNowAsync()` an —
|
||||||
sicher wiederholbar, da `EventApplier` jedes Ereignis idempotent per Upsert/Delete-by-Id
|
sicher wiederholbar, da `EventApplier` jedes Ereignis idempotent per Upsert/Delete-by-Id
|
||||||
anwendet.
|
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
|
### 10.2 Server
|
||||||
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
- [x] **10.2.1** Benutzerverwaltung/Registrierung prüfen und absichern
|
||||||
|
|||||||
Reference in New Issue
Block a user