Files
LehrerApp/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs
adminandClaude Sonnet 5 4b9bcfe85d 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>
2026-08-23 18:43:36 +02:00

320 lines
16 KiB
C#

using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den HTTP-freien Verarbeitungskern von UntisSyncService — kein echter Abruf nötig,
/// synthetischer ICS-Text nach dem realen WebUntis-Format (siehe Planungsdokument).
public sealed class UntisSyncServiceTests
{
private static string BuildIcs(string uid, string dtstart, string? summary, string description, string status = "CONFIRMED") =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" +
"BEGIN:VEVENT\n" +
$"UID:{uid}\n" +
$"STATUS:{status}\n" +
$"DTSTART;TZID=Europe/Berlin:{dtstart}\n" +
$"DTEND;TZID=Europe/Berlin:{dtstart}\n" +
(summary is null ? "" : $"SUMMARY:{summary}\n") +
$"DESCRIPTION:{description}\n" +
"END:VEVENT\nEND:VCALENDAR\n";
private static string EmptyIcs() => "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
private static UntisSyncService BuildService(FakeUntisSnapshots? snapshots = null,
FakeUntisSlotMappings? mappings = null, FakeSubstitutionEntries? substitutions = null,
FakeGroups? groups = null, FakeTimetableSlots? timetableSlots = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSchoolHolidays? schoolHolidays = null) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
snapshots ?? new FakeUntisSnapshots(), mappings ?? new FakeUntisSlotMappings(),
substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]),
timetableSlots ?? new FakeTimetableSlots(), supervisionDuties ?? new FakeSupervisionDuties(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(),
new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untissync-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void ProcessIcsText_UnveraendertesEreignis_SchreibtKeineVertretungAberSnapshot()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var service = BuildService(snapshots: snapshots, mappings: mappings);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, result.EventCount);
Assert.Equal(0, result.SubstitutionCount);
Assert.Single(snapshots.GetAll());
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions);
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Erneuter Poll mit demselben (noch immer abweichenden) Termin darf keinen zweiten
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus mehreren
// Klassen zusammengesetzt sind" — nachdem der eigentliche Vergleichsfehler (ClassToken/
// Trennzeichen) behoben war, blieben bereits erzeugte falsche Vertretungen trotzdem stehen:
// ProcessIcsText schrieb bislang nur NEUE/weiterhin abweichende Kandidaten, räumte aber nie
// eine zuvor automatisch erzeugte Zeile ab, sobald der nächste Poll gar keine Abweichung mehr
// fand. Der Nutzer sah dadurch dieselbe (jetzt stale) Karteileiche, egal wie oft er neu
// zuordnete oder pollte.
[Fact]
public void ProcessIcsText_AbweichungLoestSichSpaeterAuf_EntferntDieZuvorErzeugteVertretung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Poll 1: Fach weicht (noch) ab - Vertretung wird angelegt.
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Poll 2: derselbe Termin weicht jetzt nicht mehr ab (z.B. weil sich die WebUntis-Daten
// geändert haben oder ein Vergleichsfehler zwischenzeitlich behoben wurde) - die zuvor
// erzeugte Vertretung darf nicht als Karteileiche stehen bleiben.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(0, second.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
// ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht
// im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen
// Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates).
[Fact]
public void ProcessIcsText_VonAnfangAnFehlendeStunde_WirdAlsAusfallGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Kein Termin für missingDate im Feed (die 8c ist weg) - ein unbeteiligter, etwas
// späterer Termin belegt lediglich den Feed-Abdeckungshorizont darüber hinaus.
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
}
[Fact]
public void ProcessIcsText_FehlendeStundeAnFerientag_WirdNichtGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday
{
StartDate = DateOnly.FromDateTime(missingDate), EndDate = DateOnly.FromDateTime(missingDate),
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, schoolHolidays: schoolHolidays);
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_OhneBestaetigteZuordnung_SchreibtNieEineVertretung()
{
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void BuildMatchPreview_LiefertVorschlagFuerBekannteGruppe()
{
var group = new LearningGroup { Name = "10c" };
var service = BuildService(groups: new FakeGroups([group]));
var preview = service.BuildMatchPreview(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, preview.EventCount);
var match = Assert.Single(preview.Matches.Matches);
Assert.Equal(group.Id, match.SuggestedGroupId);
}
// ── Nutzer-Feedback: "Ich habe eine Aufsicht vertretungsweise bekommen [...] Die fehlt
// natürlich in der Woche drauf wieder. Wird dann bis zum Ende des gültigen Stundenplans diese
// Stunde als entfallene Aufsicht geführt?" — die "entfallen"-Meldung ist an die konkrete,
// tatsächlich gesehene Snapshot-Zeile eines Datums gekoppelt, nicht an eine dauerhaft
// erwartete wöchentliche Wiederholung: sobald sie einmal als entfallen gemeldet und aus dem
// Snapshot entfernt wurde, gibt es nichts mehr, das in einer Folgewoche erneut "verschwinden"
// könnte, solange WebUntis für diesen Slot keinen neuen Termin mehr listet. Mit einer
// passenden regulären SupervisionDuty, damit dieser Test ausschließlich den
// Verschwinden-Mechanismus prüft, unabhängig von der "Zusätzliche Aufsicht"-Erkennung unten.
[Fact]
public void ProcessIcsText_RegulaereAufsichtVerschwindetEinmalig_MeldetEntfallenNurEinmal()
{
var eventDate = DateTime.Today.AddDays(3); // innerhalb des 14-Tage-Lookaheads
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
// Poll 1: Feed enthält die reguläre Aufsicht wie erwartet - keine Meldung nötig.
var first = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, first.SubstitutionCount);
Assert.Single(snapshots.GetAll());
// Poll 2: WebUntis listet an diesem Slot keinen Termin mehr (einmalig von jemand anderem
// übernommen). Genau EINE "entfallen"-Meldung, Snapshot-Zeile wird bereinigt.
var second = service.ProcessIcsText(EmptyIcs());
Assert.Equal(1, second.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Empty(snapshots.GetAll());
// Poll 3, 4, ...: weiterhin kein Termin an diesem Slot - keine erneute Meldung, "bis zum
// Ende des gültigen Stundenplans" bleibt es bei dem einen Eintrag.
var third = service.ProcessIcsText(EmptyIcs());
Assert.Equal(0, third.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" — eine bestätigte Aufsichts-Zuordnung OHNE passende reguläre
// SupervisionDuty (Einstellungen "Aufsichten") ist selbst schon die meldenswerte Vertretung
// und soll deshalb sofort bei jedem Vorkommen als SubstitutionEntry sichtbar werden, nicht
// erst bei einer Abweichung davon.
[Fact]
public void ProcessIcsText_ZusaetzlicheAufsichtOhneRegulaereDuty_WirdSofortGemeldet()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Contains("Zusätzliche Aufsicht", entry.Description);
// 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.Single(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_AufsichtMitRegulaererDuty_MeldetNichtsBeiNormalerAnwesenheit()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ConfirmMappings_SpeichertAlsBestaetigt()
{
var mappings = new FakeUntisSlotMappings();
var service = BuildService(mappings: mappings);
var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, GroupId = Guid.NewGuid() };
service.ConfirmMappings([mapping]);
Assert.True(Assert.Single(mappings.GetAll()).Confirmed);
}
}