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:
2026-08-23 13:30:15 +02:00
co-authored by Claude Sonnet 5
parent e65a729f97
commit 4eb4d0a946
30 changed files with 3333 additions and 8 deletions
+432
View File
@@ -0,0 +1,432 @@
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<UntisIcsEvent> { 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<UntisIcsEvent> { 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);
}
[Fact]
public void Diff_AbweichendeKlasse_ErzeugtVertretungsstunde()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { BuildEvent("1", description: "10d HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Single(result.SubstitutionsToSave);
}
// ── 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<UntisIcsEvent> { 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<UntisIcsEvent> { BuildEvent("1", summary: "NAT", description: "10a; 10b; 10c HED") };
var result = new UntisDiffService().Diff(events, [], [mapping], Monday);
Assert.Single(result.SubstitutionsToSave);
}
[Fact]
public void Diff_StatusCancelled_ErzeugtAusfall()
{
var mapping = BuildMapping(Guid.NewGuid());
var events = new List<UntisIcsEvent> { 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<UntisSnapshotEntry>
{
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<UntisIcsEvent>
{
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<UntisSnapshotEntry>
{
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<UntisSnapshotEntry>
{
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<UntisIcsEvent> { 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<UntisSnapshotEntry> { new() { Id = existingId, Uid = "1", Date = Monday } };
var events = new List<UntisIcsEvent> { 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<UntisSnapshotEntry>
{
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<UntisIcsEvent>
{
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<UntisIcsEvent>
{
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<UntisIcsEvent>
{
// 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<UntisIcsEvent> { 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<UntisIcsEvent>
{
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<UntisSnapshotEntry>
{
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<UntisIcsEvent>
{
new() { Uid = "1", Date = Monday.AddDays(1), StartTime = mapping.StartTime, Status = "CONFIRMED" }, // Dienstag
};
var duties = new List<SupervisionDuty> { 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<UntisIcsEvent>
{
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<UntisIcsEvent>
{
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<SupervisionDuty> { 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<UntisIcsEvent> { 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);
}
}