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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 11:37:20 +02:00
co-authored by Claude Sonnet 5
parent de98b4ed54
commit fc2d7aea3e
7 changed files with 203 additions and 7 deletions
+9
View File
@@ -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<Student> all) : IStudentRepository
@@ -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";
@@ -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<SyncConflictListItem> 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;
@@ -806,6 +806,32 @@
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
IsVisible="{Binding SyncConflicts.Count}"
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht — hier steht, welche Seite jeweils übernommen wurde."/>
<ItemsControl ItemsSource="{Binding SyncConflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SyncConflictListItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding EntityDisplay}" FontSize="13"/>
<TextBlock FontSize="12" Opacity="0.6">
<Run Text="{Binding DetectedAtDisplay}"/><Run Text=" · "/><Run Text="{Binding ResolutionDisplay}"/>
</TextBlock>
</StackPanel>
<Button Grid.Column="1" Content="Gesehen" FontSize="12" Padding="10,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).MarkConflictReviewedCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</ContentPage>
+64
View File
@@ -0,0 +1,64 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Sync.Tests;
public sealed class EventQueueTests
{
[Fact]
public void MarkReviewed_EntferntKonfliktAusDerUnreviewedListe()
{
using var temp = new TempEventQueue();
var conflict = new ConflictEntry
{
LocalEvent = MakeEvent(),
RemoteEvent = MakeEvent(),
Resolution = "LocalWon",
};
temp.Queue.AddConflict(conflict);
temp.Queue.MarkReviewed(conflict.Id);
Assert.Empty(temp.Queue.GetUnreviewed());
Assert.Equal(0, temp.Queue.ConflictCount());
}
[Fact]
public void MarkReviewed_UnbekannteId_TutNichtsUndWirftNicht()
{
using var temp = new TempEventQueue();
var exception = Record.Exception(() => temp.Queue.MarkReviewed(Guid.NewGuid()));
Assert.Null(exception);
}
private static SyncEvent MakeEvent() => new()
{
DeviceId = "desktop-1",
DeviceType = DeviceType.Desktop,
EntityType = "Student",
EntityId = Guid.NewGuid().ToString(),
Operation = "Save",
Payload = "{}",
};
private sealed class TempEventQueue : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-sync-tests-eventqueue-{Guid.NewGuid():N}");
public EventQueue Queue { get; }
public TempEventQueue()
{
Directory.CreateDirectory(_directory);
Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
}
public void Dispose()
{
Queue.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
+7
View File
@@ -59,6 +59,13 @@ public class EventQueue : IDisposable
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
public void MarkReviewed(Guid id)
{
var conflict = _conflicts.FindById(id);
if (conflict is null) return;
conflict.Reviewed = true;
_conflicts.Update(conflict);
}
// ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
public void QueueAttachmentUpload(string storageId)
+7 -1
View File
@@ -1319,7 +1319,13 @@ ist aber nur aktiv, wenn eine Server-URL konfiguriert ist.
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
& angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
einen GET auf `/api/sync/status` mit optionalem Bearer-Token.
- [ ] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
- [x] **10.1.3** Konfliktanzeige in der UI — was `ConflictResolver` entscheidet, muss sichtbar sein.
**Umsetzung:** Minimale Liste im Tab "Synchronisation" (kein Feld-Diff für v1 — die
Payloads sind clientseitig verschlüsselt, ein Diff würde ohnehin nur rohes JSON zeigen).
Zeigt Entität, Zeitpunkt und welche Seite gewonnen hat, mit "Gesehen"-Aktion. Neu
`EventQueue.MarkReviewed(id)` (bisher nur `AddConflict`/`GetUnreviewed`/`ConflictCount`,
kein Weg, einen Konflikt als gesehen zu markieren).
- [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`,