WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry. Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für Stunden auf, die real unverändert sind — wird in einem Folge-Commit untersucht, deshalb vorerst auf diesem Branch statt main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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: "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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user