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>
132 lines
5.1 KiB
C#
132 lines
5.1 KiB
C#
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;
|
|
|
|
public sealed class SyncStatusViewModelTests
|
|
{
|
|
[Fact]
|
|
public void Konstruktor_EngineBereitsVorhanden_ZeigtNichtDenNichtKonfiguriertText()
|
|
{
|
|
// SyncEngine feuert sein erstes StatusChanged schon im eigenen Konstruktor - läuft der vor
|
|
// dem Abonnieren in SyncStatusViewModel (z.B. weil beide DI-Singletons sind und
|
|
// GetService<SyncEngine>() den Engine-Konstruktor erst innerhalb der VM-Factory auslöst),
|
|
// ginge dieser erste Status verloren und StatusText bliebe fälschlich beim Default.
|
|
using var temp = new TempSyncEngine();
|
|
|
|
var vm = new SyncStatusViewModel(temp.Engine);
|
|
|
|
Assert.True(vm.IsServerConfigured);
|
|
Assert.NotEqual("Kein Server konfiguriert", vm.StatusText);
|
|
Assert.Equal("Synchronisiert", vm.StatusText);
|
|
}
|
|
|
|
[Fact]
|
|
public void Konstruktor_KeineEngine_ZeigtNichtKonfiguriertText()
|
|
{
|
|
var vm = new SyncStatusViewModel(null);
|
|
|
|
Assert.False(vm.IsServerConfigured);
|
|
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 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;
|
|
public SyncEngine Engine { get; }
|
|
|
|
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(
|
|
queue, new ConflictResolver(queue), new EventApplier(_db, key),
|
|
new AttachmentSyncer(_db, _http, key), _http,
|
|
new SyncConfig { AutoSyncIntervalMinutes = 5 });
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Engine.Dispose(); // disposed auch die EventQueue
|
|
_db.Dispose();
|
|
_http.Dispose();
|
|
if (File.Exists(_queuePath)) File.Delete(_queuePath);
|
|
}
|
|
}
|
|
}
|