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); } }