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.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -38,6 +39,14 @@ public static class TestSupport
/// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe /// Kein echter HTTP-Aufruf, solange SyncSettingsService.IsLoggedIn false ist (siehe
/// BuildAiPlanningService). /// BuildAiPlanningService).
public static SyncAuthService BuildSyncAuthService() => new(new HttpClient()); 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 public class FakeStudents(List<Student> all) : IStudentRepository
@@ -2,6 +2,9 @@ using LehrerApp.Core.Models;
using LehrerApp.Core.Services; using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels.Settings; using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
using System.Linq;
using Xunit; using Xunit;
namespace LehrerApp.Desktop.Tests; namespace LehrerApp.Desktop.Tests;
@@ -10,7 +13,8 @@ public sealed class SettingsViewModelTests
{ {
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null, private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = 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, // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. // das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -27,7 +31,47 @@ public sealed class SettingsViewModelTests
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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] [Fact]
@@ -104,7 +148,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService()); TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
vm.SelectedStateName = "Bayern"; vm.SelectedStateName = "Bayern";
@@ -127,7 +171,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45"; vm.PeriodTimes[0].EndText = "08:45";
@@ -154,7 +198,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), 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].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00"; vm.PeriodTimes[0].EndText = "08:00";
@@ -6,6 +6,7 @@ using LehrerApp.Core.Services;
using LehrerApp.Data; using LehrerApp.Data;
using LehrerApp.Desktop.Services; using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Sync;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
@@ -173,6 +174,8 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _syncIsLoggedIn; [ObservableProperty] private bool _syncIsLoggedIn;
[ObservableProperty] private string _syncConnectionStatus = ""; [ObservableProperty] private string _syncConnectionStatus = "";
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
// ── Konstruktor ─────────────────────────────────────────────────────────── // ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays; private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -183,6 +186,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiPlanningService _aiPlanning; private readonly AiPlanningService _aiPlanning;
private readonly SyncSettingsService _syncSettings; private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth; private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
private readonly CompetencyCatalogImportService _catalogImport; private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -194,7 +198,7 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning, AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth) SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
{ {
_subjects = subjects; _subjects = subjects;
_domainRepo = domainRepo; _domainRepo = domainRepo;
@@ -218,6 +222,7 @@ public partial class SettingsViewModel : ObservableObject
_aiPlanning = aiPlanning; _aiPlanning = aiPlanning;
_syncSettings = syncSettings; _syncSettings = syncSettings;
_syncAuth = syncAuth; _syncAuth = syncAuth;
_eventQueue = eventQueue;
_catalogImport = new CompetencyCatalogImportService(domainRepo); _catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects(); LoadSubjects();
LoadShorthandCodes(); LoadShorthandCodes();
@@ -236,6 +241,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates(); LoadLetterTemplates();
LoadAiSettings(); LoadAiSettings();
LoadSyncSettings(); LoadSyncSettings();
LoadSyncConflicts();
} }
// ── Word-Briefvorlagen: Import und Validierung ────────────────────────── // ── Word-Briefvorlagen: Import und Validierung ──────────────────────────
@@ -436,6 +442,27 @@ public partial class SettingsViewModel : ObservableObject
AppBootstrapper.RestartApplication(); 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 ───────────────────────────────────── // ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes() 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 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 class SchoolHolidayItem(SchoolHoliday h)
{ {
public Guid Id { get; } = h.Id; public Guid Id { get; } = h.Id;
@@ -806,6 +806,32 @@
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap" <TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/> 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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</ContentPage> </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 void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList(); public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed); 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) ──── // ── Anhang-Warteliste (getrennt von der JSON-Ereignis-Outbox, siehe AttachmentSyncer) ────
public void QueueAttachmentUpload(string storageId) 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 **Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
& angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über & angemeldet / erreichbar aber nicht angemeldet bzw. Token ungültig / nicht erreichbar) über
einen GET auf `/api/sync/status` mit optionalem Bearer-Token. 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. - [x] **10.1.4** Manuelles Auslösen einer vollständigen Synchronisation.
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`, **Umsetzung:** War bereits vorhanden (`SyncStatusViewModel.SyncNowCommand`,