fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen

- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:43:36 +02:00
co-authored by Claude Sonnet 5
parent 4eb4d0a946
commit 4b9bcfe85d
7 changed files with 240 additions and 14 deletions
+85 -1
View File
@@ -54,6 +54,10 @@ public sealed class UntisDiffServiceTests
Assert.Equal(SubstitutionKind.Lesson, entry.Kind);
Assert.Equal("1", entry.ExternalId);
Assert.Equal(1, entry.PeriodNumber);
// Diagnose direkt in der Beschreibung (siehe DeviationReason) - damit der Nutzer im
// Stundenplan selbst ablesen kann, welcher Vergleich genau fehlgeschlagen ist, ohne
// Rohdaten aus dem Feed teilen zu müssen.
Assert.Contains("Fach weicht ab", entry.Description);
}
[Fact]
@@ -64,7 +68,8 @@ public sealed class UntisDiffServiceTests
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Single(result.SubstitutionsToSave);
var entry = Assert.Single(result.SubstitutionsToSave);
Assert.Contains("Klasse weicht ab", entry.Description);
}
// ── Regression: "Der Mathematik E-Kurs [...] ist ein Kurs aus den Klassen 10a, 10b und 10c
@@ -105,6 +110,85 @@ public sealed class UntisDiffServiceTests
Assert.Single(result.SubstitutionsToSave);
}
// ── Regression, zweiter Anlauf: der erste Fix (Leerzeichen nur auf evt.Description-Seite
// entfernt) reichte nicht, wenn mapping.ClassToken SELBST ein eingebettetes Leerzeichen trägt
// — das passiert, wenn die reale DESCRIPTION Klassen nicht mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und speicherte "10a, 10b, 10c" als EIN Token samt
// Kommas/Leerzeichen statt drei getrennte Tokens. Ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann strukturell NIE finden, unabhängig von der im Dialog
// gewählten Gruppe - genau das vom Nutzer beschriebene "immer noch nicht behoben".
[Fact]
public void Diff_KombinierteGruppeMitKommaGetrenntemClassToken_ErzeugtKeineFalscheVertretung()
{
var mapping = new UntisSlotMapping
{
// So sieht ClassToken aus, wenn ExtractClassTokens die Klassen mangels ";" nicht
// aufsplitten konnte und "10a, 10b, 10c" als ein einziges Token übernommen hat.
Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a, 10b, 10c",
GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
};
var events = new List<UntisIcsEvent> { BuildEvent("1", summary: "Mat_E", description: "10a, 10b, 10c HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus
// mehreren Klassen zusammengesetzt sind" — eine zuvor erkannte Abweichung, die beim nächsten
// Poll nicht mehr besteht, muss die ursprünglich erzeugte Vertretung wieder aufräumen, statt
// als Karteileiche stehen zu bleiben.
[Fact]
public void Diff_UnveraendertesEreignis_MeldetDessenExternalIdZumAufraeumen()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Contains("1", result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_AufsichtWirdZurRegulaerenDuty_MeldetExternalIdZumAufraeumen()
{
var mapping = BuildSupervisionMapping();
var events = new List<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" },
};
var duties = new List<SupervisionDuty> { new() { Weekday = mapping.Weekday, AfterPeriod = mapping.AfterPeriod!.Value } };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties);
Assert.Empty(result.SubstitutionsToSave);
Assert.Contains("1", result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_VormalsFehlendeStundeIstWiederImFeed_MeldetMissingExternalIdZumAufraeumen()
{
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "8c",
GroupId = groupId, PeriodNumber = 3, Confirmed = true,
};
var thisThursday = Monday.AddDays(3);
var currentEvents = new List<UntisIcsEvent>
{
new() { Uid = "regular-1", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "8c HED" },
};
var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday);
Assert.Empty(result.SubstitutionsToSave);
var expectedMissingId = $"missing-{mapping.Id}-{thisThursday:yyyyMMdd}";
Assert.Contains(expectedMissingId, result.SubstitutionExternalIdsToDelete);
}
[Fact]
public void Diff_StatusCancelled_ErzeugtAusfall()
{