diff --git a/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs b/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs index 41e8bf1..2dbbd24 100644 --- a/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs +++ b/LehrerApp.Desktop.Tests/WebUntisRestSyncProcessorTests.cs @@ -61,6 +61,75 @@ public sealed class WebUntisRestSyncProcessorTests Assert.Empty(exams.GetAll()); } + [Fact] + public void Process_DoppeltAngelegteArbeitDurchGeraeteSync_WirdAufEinenDatensatzReduziert() + { + var group = new LearningGroup { Id = Guid.NewGuid(), Name = "Mat_E" }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 40), Summary = "Mat_E", + ClassToken = "10a", Kind = SubstitutionKind.Lesson, GroupId = group.Id, + PeriodNumber = 3, CoveredPeriods = [3, 4], Confirmed = true, + }); + var exam = Entry([2002979, 2002982], "2026-09-29T09:40", "2026-09-29T11:10", + "EXAM", "REGULAR", "Mat_E", "10a", "Arbeit 1. Mathearbeit"); + var externalId = WebUntisRestSyncProcessor.ExamPrefix + "2002979-2002982"; + // Zwei Geräte haben dieselbe WebUntis-Arbeit unabhängig voneinander (vor dem gegenseitigen + // Sync) mit derselben ExternalId, aber unterschiedlicher Id angelegt. + var olderDuplicate = new Exam + { + GroupId = group.Id, Date = new DateOnly(2026, 9, 29), Title = "Arbeit 1. Mathearbeit", + ExternalId = externalId, CreatedAt = new DateTime(2026, 9, 22, 8, 0, 0, DateTimeKind.Utc), + }; + var newerDuplicate = new Exam + { + GroupId = group.Id, Date = new DateOnly(2026, 9, 29), Title = "Arbeit 1. Mathearbeit", + ExternalId = externalId, CreatedAt = new DateTime(2026, 9, 22, 9, 0, 0, DateTimeKind.Utc), + }; + var exams = new RecordingExams([olderDuplicate, newerDuplicate]); + var processor = BuildProcessor(mappings, new FakeSubstitutionEntries(), exams, [group]); + + var result = processor.Process([exam], Monday, Monday.AddDays(13), Monday.AddDays(27)); + + var remaining = Assert.Single(exams.GetAll()); + Assert.Equal(olderDuplicate.Id, remaining.Id); + Assert.Equal(1, result.ExamChanges); + } + + [Fact] + public void Process_DupliziertMitBereitsBenoteterKlausur_LoeschtNurDenNochPlanentenDatensatz() + { + var group = new LearningGroup { Id = Guid.NewGuid(), Name = "Mat_E" }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 40), Summary = "Mat_E", + ClassToken = "10a", Kind = SubstitutionKind.Lesson, GroupId = group.Id, + PeriodNumber = 3, CoveredPeriods = [3, 4], Confirmed = true, + }); + var exam = Entry([2002979, 2002982], "2026-09-29T09:40", "2026-09-29T11:10", + "EXAM", "REGULAR", "Mat_E", "10a", "Arbeit 1. Mathearbeit"); + var externalId = WebUntisRestSyncProcessor.ExamPrefix + "2002979-2002982"; + var gradedDuplicate = new Exam + { + GroupId = group.Id, Date = new DateOnly(2026, 9, 29), Title = "Arbeit 1. Mathearbeit", + ExternalId = externalId, Status = ExamStatus.Graded, + }; + var plannedDuplicate = new Exam + { + GroupId = group.Id, Date = new DateOnly(2026, 9, 29), Title = "Arbeit 1. Mathearbeit", + ExternalId = externalId, + }; + var exams = new RecordingExams([gradedDuplicate, plannedDuplicate]); + var processor = BuildProcessor(mappings, new FakeSubstitutionEntries(), exams, [group]); + + processor.Process([exam], Monday, Monday.AddDays(13), Monday.AddDays(27)); + + var remaining = Assert.Single(exams.GetAll()); + Assert.Equal(gradedDuplicate.Id, remaining.Id); + } + [Fact] public void Process_ManuelleArbeitAmSelbenTag_WirdNichtDupliziertOderGeloescht() { diff --git a/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs b/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs index 709140a..bdf4396 100644 --- a/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs +++ b/LehrerApp.Desktop/Services/WebUntisRestSyncProcessor.cs @@ -138,6 +138,7 @@ public sealed class WebUntisRestSyncProcessor( var changes = 0; var desiredIds = new HashSet(); var allExams = exams.GetAll(); + changes += DeduplicateExams(allExams); foreach (var entry in examEntries) { var periods = ResolveCoveredPeriods(entry.Start, entry.End); @@ -185,6 +186,34 @@ public sealed class WebUntisRestSyncProcessor( return changes; } + /// Räumt Klausuren auf, die zwei Geräte unabhängig voneinander mit derselben + /// aber unterschiedlicher angelegt haben, + /// weil jedes Gerät den anderen Datensatz zum Zeitpunkt seines eigenen Polls noch nicht + /// gesynct hatte (Race Condition zwischen WebUntis-REST-Abgleich und Geräte-Sync). Pro Gruppe + /// gleicher ExternalId gewinnt der am weitesten bearbeitete Datensatz (nicht mehr "Planned"), + /// sonst der zuerst angelegte; bereits bearbeitete Duplikate werden nie automatisch gelöscht, + /// um keine eingetragenen Ergebnisse zu verlieren - dieselbe Zurückhaltung wie beim + /// Stale-Abbau unten (nur `Status == Planned` wird automatisch entfernt). + private int DeduplicateExams(List allExams) + { + var changes = 0; + foreach (var group in allExams.Where(exam => exam.ExternalId is not null) + .GroupBy(exam => exam.ExternalId) + .Where(group => group.Count() > 1)) + { + var ordered = group.OrderBy(exam => exam.Status == ExamStatus.Planned ? 1 : 0) + .ThenBy(exam => exam.CreatedAt).ToList(); + foreach (var duplicate in ordered.Skip(1)) + { + if (duplicate.Status != ExamStatus.Planned) continue; + exams.Delete(duplicate.Id); + allExams.Remove(duplicate); + changes++; + } + } + return changes; + } + private UntisSlotMapping? FindMapping(List candidates, DayOfWeek weekday, TimeOnly start, List periods, UntisTimetableGridEntryDto entry) {