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:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user