This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user