Fix: Sync Log und verwaise Einträge
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-07 17:09:38 +02:00
parent 835cccadec
commit 86e114580f
12 changed files with 145 additions and 12 deletions
@@ -905,6 +905,25 @@ public sealed class ClassTeacherViewModelsTests
Assert.Equal(["Ada"], result.Select(d => d.Title));
}
[Fact]
public void FilterOwnDocumentation_ZeigtVerwaistenKlassenEintragUndFindetTeilnehmerPerVorname()
{
var rosterMatches = new List<(Guid StudentId, string DisplayName)>
{
(Guid.NewGuid(), "Karim Alshurbaji"), (Guid.NewGuid(), "Saleh Al-Anezi"),
};
var orphan = new Documentation
{
StudentId = Guid.Empty, GroupId = null, Date = new DateOnly(2026, 9, 7),
Title = "Vorfall", Participants = ["Karim", "Saleh"],
};
var result = ClassTeacherDetailsViewModel.FilterOwnDocumentation([orphan], rosterMatches,
new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), "Karim");
Assert.Same(orphan, Assert.Single(result));
}
// ── Vorgang: Fallmappe für Klassenbuch- und Dokumentationseinträge ───────────────────────────
[Fact]
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.StudentError);
}
[Fact]
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
{
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Contains("noch nicht geladen", vm.StudentError);
}
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
@@ -219,6 +219,25 @@ public sealed class SettingsViewModelTests
Assert.Empty(queue.GetUnreviewed());
}
[Fact]
public void ClearSyncConflicts_LeertProtokollUndQueue()
{
var queue = TestSupport.BuildEventQueue();
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
queue.AddConflict(new ConflictEntry
{
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
Resolution = resolution,
});
var vm = BuildViewModel(eventQueue: queue);
vm.ClearSyncConflictsCommand.Execute(null);
Assert.Empty(vm.SyncConflicts);
Assert.Empty(queue.GetUnreviewed());
}
[Fact]
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
{
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Equal(0, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
+19 -2
View File
@@ -118,11 +118,20 @@ public class UntisSyncService : IDisposable
var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today,
existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today));
var savedSubstitutionCount = 0;
foreach (var candidate in diffResult.SubstitutionsToSave)
{
var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null;
if (existing is not null) candidate.Id = existing.Id;
// Der Diff liefert auch weiterhin bestehende WebUntis-Abweichungen als Kandidaten.
// Ein unverändertes Upsert würde über Repository.OnChange bei jedem Poll erneut ein
// Sync-Ereignis erzeugen und bei vielen aktiven Abweichungen das Protokoll fluten.
if (existing is not null)
{
candidate.Id = existing.Id;
if (SubstitutionContentEquals(existing, candidate)) continue;
}
_substitutions.Save(candidate);
savedSubstitutionCount++;
}
// Zuvor automatisch erzeugte Einträge, die jetzt nicht (mehr) gebraucht werden (siehe
// UntisDiffResult.SubstitutionExternalIdsToDelete) - existiert keiner mit dieser
@@ -135,9 +144,17 @@ public class UntisSyncService : IDisposable
foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot);
foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id);
return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count);
return new UntisPollResult(events.Count, savedSubstitutionCount);
}
private static bool SubstitutionContentEquals(SubstitutionEntry left, SubstitutionEntry right) =>
left.Date == right.Date && left.Kind == right.Kind &&
left.PeriodNumber == right.PeriodNumber && left.AfterPeriod == right.AfterPeriod &&
left.FromPeriod == right.FromPeriod && left.ToPeriod == right.ToPeriod &&
left.IsAllDay == right.IsAllDay && left.GroupId == right.GroupId &&
left.GroupLabel == right.GroupLabel && left.Description == right.Description &&
left.Notes == right.Notes && left.ExternalId == right.ExternalId;
// Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster
// hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive
// "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall
@@ -199,6 +199,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
public bool ShowAbsenceListEmpty => !ShowMonthlyCalendar && !HasAbsenceEntries;
public bool HasCategoryAggregates => CategoryAggregates.Count > 0;
public bool HasOwnDocumentationEntries => OwnDocumentationEntries.Count > 0;
public bool CanAddOwnDocumentation => !Busy && _rosterMatches.Count > 0;
/// Die Kategorien-Chipreihe fasst nur WebUntis-Kategorien zusammen (<see cref="CategoryAggregates"/>)
/// — im Modus "Eigene Dokumentation" ausgeblendet, dort gibt es kein Äquivalent zu CategoryGroup.
public bool ShowCategoryAggregates => HasCategoryAggregates && !ShowOwnDocumentation;
@@ -431,7 +432,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
_loadedStart, _loadedEnd, StudentFilter);
foreach (var d in entries)
{
var name = _rosterMatches.First(m => m.StudentId == d.StudentId).DisplayName;
var name = DocumentationStudentDisplay(d, _rosterMatches);
OwnDocumentationEntries.Add(new DocumentationItem(d, name));
}
OwnDocumentationCount = OwnDocumentationEntries.Count;
@@ -452,7 +453,9 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
{
var matchedIds = rosterMatches.Select(m => m.StudentId).ToHashSet();
return all
.Where(d => !d.IsDeleted && matchedIds.Contains(d.StudentId) && d.Date >= start && d.Date <= end)
.Where(d => !d.IsDeleted &&
(matchedIds.Contains(d.StudentId) || IsUnassignedClassDocumentation(d)) &&
d.Date >= start && d.Date <= end)
.Where(d => MatchesOwnDocStudentFilter(d, rosterMatches, studentFilter))
.OrderByDescending(d => d.IsDraft).ThenByDescending(d => d.Date)
.ToList();
@@ -462,14 +465,37 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches, string studentFilter)
{
if (string.IsNullOrWhiteSpace(studentFilter)) return true;
if (IsUnassignedClassDocumentation(d))
return d.Participants.Any(p => NameContainsSearchTerm(p, studentFilter));
var name = rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName;
return name is not null && UntisNameMatching.NamesMatch(name, studentFilter);
return name is not null && NameContainsSearchTerm(name, studentFilter);
}
private static bool IsUnassignedClassDocumentation(Documentation d) =>
d.StudentId == Guid.Empty && d.GroupId is null;
private static bool NameContainsSearchTerm(string name, string search)
{
if (UntisNameMatching.NamesMatch(name, search)) return true;
var searchKey = UntisNameMatching.NameKey(search);
return searchKey.Length > 0 && UntisNameMatching.NameKey(name).Split(' ').Contains(searchKey);
}
private static string DocumentationStudentDisplay(Documentation d,
IReadOnlyList<(Guid StudentId, string DisplayName)> rosterMatches)
{
if (d.StudentId != Guid.Empty)
return rosterMatches.FirstOrDefault(m => m.StudentId == d.StudentId).DisplayName
?? "Unbekannter Schülerbezug";
return d.Participants.Count > 0
? $"Ohne festen Bezug · {string.Join(", ", d.Participants)}"
: "Ohne Schülerbezug";
}
[RelayCommand]
private async Task AddOwnDocumentation()
{
if (OnEditOwnDocumentation is null) return;
if (OnEditOwnDocumentation is null || !CanAddOwnDocumentation) return;
var options = _rosterMatches.Select(m => new StudentOption(m.StudentId, m.DisplayName)).ToList();
var result = await OnEditOwnDocumentation(options, null);
if (result is null) return;
@@ -553,6 +579,7 @@ public partial class ClassTeacherDetailsViewModel : ObservableObject
OnPropertyChanged(nameof(HasCategoryAggregates));
OnPropertyChanged(nameof(ShowCategoryAggregates));
OnPropertyChanged(nameof(HasOwnDocumentationEntries));
OnPropertyChanged(nameof(CanAddOwnDocumentation));
OnPropertyChanged(nameof(UntisCriticalCount));
}
}
@@ -229,6 +229,13 @@ public partial class SettingsViewModel
_eventQueue.MarkReviewed(item.Id);
SyncConflicts.Remove(item);
}
[RelayCommand]
private void ClearSyncConflicts()
{
_eventQueue.ClearConflicts();
SyncConflicts.Clear();
}
}
public class SyncConflictListItem(ConflictEntry c)
@@ -304,6 +304,11 @@ public partial class DocumentationDialogViewModel : ObservableObject
var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
if (!CanPickStudent && _studentId == Guid.Empty && _contextGroupId is null)
{
StudentError = "Die Schülerliste ist noch nicht geladen. Dialog schließen und erneut öffnen.";
valid = false;
}
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
@@ -175,6 +175,7 @@
<Grid Grid.Row="4" IsVisible="{Binding ShowOwnDocumentation}" RowDefinitions="Auto,*">
<Button Grid.Row="0" Content=" Eintrag" Command="{Binding AddOwnDocumentationCommand}"
IsEnabled="{Binding CanAddOwnDocumentation}"
HorizontalAlignment="Right" Margin="0,0,0,8"/>
<ScrollViewer Grid.Row="1">
<StackPanel>
@@ -895,11 +895,15 @@
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/>
<Grid ColumnDefinitions="*,Auto" Margin="0,10,0,0" IsVisible="{Binding SyncConflicts.Count}">
<TextBlock Grid.Column="0" Text="Automatisch gelöste Konflikte" FontSize="14"
FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Protokoll leeren" FontSize="11" Padding="8,3"
Command="{Binding ClearSyncConflictsCommand}"/>
</Grid>
<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."/>
Text="Ein anderes Gerät hat dieselbe Änderung gleichzeitig gemacht. Der Konflikt ist bereits automatisch aufgelöst; hier steht nur das Ergebnis zur Kontrolle."/>
<ItemsControl ItemsSource="{Binding SyncConflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SyncConflictListItem">
+19
View File
@@ -33,6 +33,25 @@ public sealed class EventQueueTests
Assert.Null(exception);
}
[Fact]
public void ClearConflicts_EntferntAlleKonflikte()
{
using var temp = new TempEventQueue();
temp.Queue.AddConflict(new ConflictEntry
{
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "LocalWon",
});
temp.Queue.AddConflict(new ConflictEntry
{
LocalEvent = MakeEvent(), RemoteEvent = MakeEvent(), Resolution = "RemoteWon",
});
temp.Queue.ClearConflicts();
Assert.Empty(temp.Queue.GetUnreviewed());
Assert.Equal(0, temp.Queue.ConflictCount());
}
[Fact]
public void GetKnownServerSeq_UnbekannteEntitaet_LiefertNull()
{
+1
View File
@@ -74,6 +74,7 @@ public class EventQueue : IDisposable
conflict.Reviewed = true;
_conflicts.Update(conflict);
}
public void ClearConflicts() => _conflicts.DeleteAll();
// ── Lokale Versionsverfolgung je Entität (optimistische Nebenläufigkeitskontrolle) ──────
// Merkt sich pro Entität die zuletzt bekannte ServerSeq — Grundlage für SyncEvent.