Fix: Klausur Pull aus Untis
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-24 00:19:37 +02:00
1 parent 30f0fee0b2
commit 45b7a2b7e6
2 files changed
+98

No files matched your search

@@ -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()
{
@@ -138,6 +138,7 @@ public sealed class WebUntisRestSyncProcessor(
var changes = 0;
var desiredIds = new HashSet<string>();
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;
}
/// <summary>Räumt Klausuren auf, die zwei Geräte unabhängig voneinander mit derselben
/// <see cref="Exam.ExternalId"/> aber unterschiedlicher <see cref="Exam.Id"/> 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).</summary>
private int DeduplicateExams(List<Exam> 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<UntisSlotMapping> candidates, DayOfWeek weekday, TimeOnly start,
List<int> periods, UntisTimetableGridEntryDto entry)
{