using LehrerApp.Core.Models; using LehrerApp.Core.Services; using Xunit; namespace LehrerApp.Tests; public sealed class UntisDiffServiceTests { private static readonly DateOnly Monday = new(2026, 8, 17); // ein Montag private static readonly TimeOnly SlotStart = new(7, 50); private static readonly TimeOnly SlotEnd = new(9, 20); private static UntisSlotMapping BuildMapping(Guid groupId, bool confirmed = true) => new() { Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "SOL", ClassToken = "10c", GroupId = groupId, PeriodNumber = 1, Confirmed = confirmed, }; private static UntisSlotMapping BuildSupervisionMapping(bool confirmed = true) => new() { Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = confirmed, }; private static UntisIcsEvent BuildEvent(string uid, string? summary = "SOL", string description = "10c HED", DateOnly? date = null, string status = "CONFIRMED") => new() { Uid = uid, Date = date ?? Monday, StartTime = SlotStart, EndTime = SlotEnd, Summary = summary, Description = description, Status = status, }; [Fact] public void Diff_UnveraendertesEreignis_ErzeugtKeineVertretung() { var mapping = BuildMapping(Guid.NewGuid()); var events = new List { BuildEvent("1") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); Assert.Empty(result.SubstitutionsToSave); var snapshot = Assert.Single(result.SnapshotToSave); Assert.Equal("1", snapshot.Uid); } [Fact] public void Diff_AbweichendesFach_ErzeugtVertretungsstunde() { var mapping = BuildMapping(Guid.NewGuid()); var events = new List { BuildEvent("1", summary: "NAT") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); 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] public void Diff_AbweichendeKlasse_ErzeugtVertretungsstunde() { var mapping = BuildMapping(Guid.NewGuid()); var events = new List { BuildEvent("1", description: "10d HED") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); 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 // [...] Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt. Diese manuelle // Verknüpfung ist aber jetzt scheinbar vergessen und es taucht jedes Mal die Vertretung auf" // — WebUntis trennt kombinierte Klassen in DESCRIPTION mit "; " (Semikolon+Leerzeichen), der // gespeicherte ClassToken aber kompakt ohne Leerzeichen ("10a;10b;10c") - ein reiner // Teilstring-Vergleich schlug dadurch für JEDE bestätigte kombinierte Gruppe fehl. [Fact] public void Diff_KombinierteGruppeUnveraendert_ErzeugtKeineFalscheVertretung() { var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a;10b;10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, }; // Echtes WebUntis-Format: "; " (Semikolon + Leerzeichen) zwischen den Klassen. var events = new List { BuildEvent("1", summary: "Mat_E", description: "10a; 10b; 10c HED") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); Assert.Empty(result.SubstitutionsToSave); } [Fact] public void Diff_KombinierteGruppeEchteAbweichung_WirdWeiterhinErkannt() { var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, StartTime = SlotStart, Summary = "Mat_E", ClassToken = "10a;10b;10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, }; var events = new List { BuildEvent("1", summary: "NAT", description: "10a; 10b; 10c HED") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); 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 { 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 { 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 { new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, }; var duties = new List { 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 { 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() { var mapping = BuildMapping(Guid.NewGuid()); var events = new List { BuildEvent("1", status: "CANCELLED") }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Cancelled, entry.Kind); Assert.Equal(1, entry.PeriodNumber); } [Fact] public void Diff_VerschwundenerTerminInnerhalbLookahead_ErzeugtAusfall() { var mapping = BuildMapping(Guid.NewGuid()); var nextMonday = Monday.AddDays(7); // gleicher Wochentag wie das Mapping, innerhalb des Lookaheads var previousSnapshot = new List { new() { Id = Guid.NewGuid(), Uid = "1", Date = nextMonday, StartTime = SlotStart, EndTime = SlotEnd }, }; // "Heute" (Monday, der today-Parameter unten) fand ganz normal statt - sonst würde die // neue aktive Prüfung (siehe unten) auch dafür fälschlich einen Ausfall vermuten, da für // dieses Testszenario sonst kein Termin am Montag-Slot im aktuellen Fetch vorläge. Der // zweite, unbeteiligte Termin belegt den Feed-Abdeckungshorizont über nextMonday hinaus - // ohne mindestens einen Termin im aktuellen Fetch wüsste Diff nicht, wie weit WebUntis den // Kalender überhaupt schon veröffentlicht hat. var currentEvents = new List { BuildEvent("today-1", date: Monday), BuildEvent("other", summary: "ANDERES", description: "9x HED", date: nextMonday.AddDays(3)), }; var result = new UntisDiffService().Diff(currentEvents, previousSnapshot, [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Cancelled, entry.Kind); Assert.Equal(nextMonday, entry.Date); // Anders als bei Aufsichten (siehe unten) räumt die aktive Prüfung für // Unterrichtsstunden die alte Snapshot-Zeile nicht explizit weg - sie bleibt als // harmloser Datensatz stehen, auf den nichts mehr zugreift (gleiche Haltung wie bei // verwaisten UntisSlotMapping-Zeilen, siehe TODO.md). Assert.Empty(result.SnapshotIdsToDelete); } [Fact] public void Diff_VerschwundenerTerminAusserhalbLookahead_WirdIgnoriert() { var mapping = BuildMapping(Guid.NewGuid()); var previousSnapshot = new List { new() { Id = Guid.NewGuid(), Uid = "1", Date = Monday.AddDays(30), StartTime = SlotStart, EndTime = SlotEnd }, }; var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday); Assert.Empty(result.SubstitutionsToSave); Assert.Empty(result.SnapshotIdsToDelete); } [Fact] public void Diff_VerschwundenerTerminInDerVergangenheit_WirdIgnoriert() { var mapping = BuildMapping(Guid.NewGuid()); var previousSnapshot = new List { new() { Id = Guid.NewGuid(), Uid = "1", Date = Monday.AddDays(-1), StartTime = SlotStart, EndTime = SlotEnd }, }; var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday); Assert.Empty(result.SubstitutionsToSave); } [Fact] public void Diff_OhneBestaetigteZuordnung_ErzeugtNieEineVertretung() { var unconfirmed = BuildMapping(Guid.NewGuid(), confirmed: false); var events = new List { BuildEvent("1", summary: "NAT") }; var result = new UntisDiffService().Diff(events, [], [unconfirmed], Monday); Assert.Empty(result.SubstitutionsToSave); // Der Termin fließt trotzdem in den Snapshot ein (für einen späteren Abgleich, sobald // die Zuordnung bestätigt wird). Assert.Single(result.SnapshotToSave); } [Fact] public void Diff_VorhandeneSnapshotZeile_BehaeltIhreId() { var mapping = BuildMapping(Guid.NewGuid()); var existingId = Guid.NewGuid(); var previousSnapshot = new List { new() { Id = existingId, Uid = "1", Date = Monday } }; var events = new List { BuildEvent("1") }; var result = new UntisDiffService().Diff(events, previousSnapshot, [mapping], Monday); Assert.Equal(existingId, Assert.Single(result.SnapshotToSave).Id); } // ── Regression: "Donnerstag nächste Woche in der 3. Stunde ist ein Stundenausfall" ───────── [Fact] public void Diff_VerschwundeneStundeAnKonkretemDonnerstag_ErzeugtAusfallMitRichtigerStundennummer() { var groupId = Guid.NewGuid(); var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Thursday, StartTime = new TimeOnly(9, 40), Summary = "NAT", ClassToken = "6a", GroupId = groupId, PeriodNumber = 3, Confirmed = true, }; var nextThursday = Monday.AddDays(((int)DayOfWeek.Thursday - (int)Monday.DayOfWeek + 7) % 7 + 7); var previousSnapshot = new List { new() { Id = Guid.NewGuid(), Uid = "38818-1", Date = nextThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10) }, }; // Diese Woche fand die Stunde ganz normal statt (sonst würde die neue aktive Prüfung dafür // ebenfalls fälschlich einen Ausfall vermuten) - nur nextThursday fehlt. Der dritte, // unbeteiligte Termin belegt den Feed-Abdeckungshorizont über nextThursday hinaus. var thisThursday = Monday.AddDays(3); var currentEvents = new List { new() { Uid = "38818-0", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "6a HED" }, new() { Uid = "other", Date = nextThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" }, }; var result = new UntisDiffService().Diff(currentEvents, previousSnapshot, [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Cancelled, entry.Kind); Assert.Equal(3, entry.PeriodNumber); Assert.Equal(nextThursday, entry.Date); } // ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht // im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht stattfinden" — // anders als der Donnerstag-Fall oben gab es hier NIE einen vorherigen Snapshot-Eintrag: der // Ausfall stand schon beim allerersten Abruf fest, WebUntis hat dafür nie einen Termin // gelistet. Reines Schnappschuss-Diffing (vorher/nachher vergleichen) kann das grundsätzlich // nicht erkennen — nur die aktive "erwartetes Datum fehlt komplett" Prüfung. [Fact] public void Diff_VonAnfangAnFehlendeStunde_OhneVorherigenSnapshot_ErzeugtTrotzdemAusfall() { 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 nextThursday = thisThursday.AddDays(7); // Kein previousSnapshot - der erste Abruf überhaupt zeigt die 8c bereits als weg. var currentEvents = new List { new() { Uid = "regular-1", Date = thisThursday, StartTime = new TimeOnly(9, 40), EndTime = new TimeOnly(11, 10), Summary = "NAT", Description = "8c HED" }, // nextThursday absichtlich ausgelassen - die 8c ist an diesem Tag weg. new() { Uid = "other", Date = nextThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" }, }; var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Cancelled, entry.Kind); Assert.Equal(3, entry.PeriodNumber); Assert.Equal(nextThursday, entry.Date); } [Fact] public void Diff_FehlendeStundeAnFerientag_WirdNichtAlsAusfallGemeldet() { 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 holidayThursday = Monday.AddDays(3); var currentEvents = new List { // holidayThursday absichtlich ausgelassen - liegt aber in den Ferien, kein Ausfall. new() { Uid = "other", Date = holidayThursday.AddDays(1), StartTime = new TimeOnly(9, 40), Summary = "ANDERES", Description = "9x HED" }, }; var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday, freeDates: [holidayThursday]); Assert.Empty(result.SubstitutionsToSave); } [Fact] public void Diff_FehlendeStundeJenseitsDesFeedHorizonts_WirdIgnoriert() { 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, }; // Der Feed deckt nur bis übermorgen ab - ein Donnerstag weit dahinter darf nicht als // Ausfall gelten, nur weil der Feed noch nicht so weit veröffentlicht wurde. var currentEvents = new List { BuildEvent("nearby", date: Monday.AddDays(2)) }; var result = new UntisDiffService().Diff(currentEvents, [], [mapping], Monday); Assert.Empty(result.SubstitutionsToSave); } // ── Aufsichten (Nutzer-Feedback: "Zwei Termine sind meine Aufsichten, die nicht zugeordnet // werden können") ──────────────────────────────────────────────────────────────────────── [Fact] public void Diff_AufsichtStatusCancelled_ErzeugtSupervisionEintragStattAusfall() { var mapping = BuildSupervisionMapping(); var events = new List { new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CANCELLED" }, // Dienstag }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Supervision, entry.Kind); Assert.Equal(1, entry.AfterPeriod); Assert.Null(entry.PeriodNumber); } [Fact] public void Diff_VerschwundeneAufsicht_ErzeugtSupervisionEintrag() { var mapping = BuildSupervisionMapping(); var nextTuesday = Monday.AddDays(1); var previousSnapshot = new List { new() { Id = Guid.NewGuid(), Uid = "1", Date = nextTuesday, StartTime = mapping.StartTime }, }; var result = new UntisDiffService().Diff([], previousSnapshot, [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Supervision, entry.Kind); Assert.Equal(1, entry.AfterPeriod); } [Fact] public void Diff_AufsichtMitPassenderRegulaererDuty_ErzeugtBeiNormalerAnwesenheitKeinenEintrag() { var mapping = BuildSupervisionMapping(); var events = new List { new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag }; var duties = new List { new() { Weekday = mapping.Weekday, AfterPeriod = mapping.AfterPeriod!.Value } }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties); Assert.Empty(result.SubstitutionsToSave); } [Fact] public void Diff_AufsichtOhnePassendeRegulaereDuty_ErzeugtSofortZusaetzlicheAufsichtsMeldung() { // Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der // Sync nur so halb Sinn" - ohne passende SupervisionDuty ist jedes Vorkommen selbst schon // die meldenswerte Vertretung. var mapping = BuildSupervisionMapping(); var events = new List { new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(SubstitutionKind.Supervision, entry.Kind); Assert.Equal(1, entry.AfterPeriod); Assert.Equal("1", entry.ExternalId); Assert.Contains("Zusätzliche Aufsicht", entry.Description); } [Fact] public void Diff_AufsichtOhnePassendeRegulaereDutyAberFalscherWeekday_ErzeugtKeineMeldung() { var mapping = BuildSupervisionMapping(); var events = new List { new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag }; // Passende Duty existiert, aber an einem ANDEREN Wochentag - darf nicht fälschlich als // regulär durchgehen. var duties = new List { new() { Weekday = DayOfWeek.Wednesday, AfterPeriod = mapping.AfterPeriod!.Value } }; var result = new UntisDiffService().Diff(events, [], [mapping], Monday, existingSupervisionDuties: duties); Assert.Single(result.SubstitutionsToSave); } // ── Robustheit gegen mehrere Mappings für denselben Slot (siehe UntisMappingReviewDialog) ─── [Fact] public void Diff_MehrereMappingsFuerDenselbenSlot_WirftNichtSondernNutztNeuesten() { var groupId = Guid.NewGuid(); var older = new UntisSlotMapping { Weekday = DayOfWeek.Monday, StartTime = SlotStart, GroupId = groupId, PeriodNumber = 1, Confirmed = true, CreatedAt = DateTime.UtcNow.AddDays(-2), }; var newer = new UntisSlotMapping { Weekday = DayOfWeek.Monday, StartTime = SlotStart, GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, CreatedAt = DateTime.UtcNow, }; var events = new List { BuildEvent("1", summary: "NAT") }; var result = new UntisDiffService().Diff(events, [], [older, newer], Monday); var entry = Assert.Single(result.SubstitutionsToSave); Assert.Equal(newer.GroupId, entry.GroupId); } }