320 lines
16 KiB
C#
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_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
|
|
{
|
|
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(0, 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(0, 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);
|
|
}
|
|
}
|