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
+150
View File
@@ -0,0 +1,150 @@
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
/// Tests gegen synthetische, aber am realen WebUntis-Export orientierte ICS-Fixtures (siehe
/// Planungsdokument "WebUntis-iCal-Abgleich") — kein Rückgriff auf den echten Nutzer-Feed.
public class IcsParserTests
{
private const string CalendarHeader =
"BEGIN:VCALENDAR\nPRODID:-//Ben Fortuna//iCal4j 1.0//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\n";
private const string CalendarFooter = "END:VCALENDAR\n";
private static string Wrap(string vevents) => CalendarHeader + vevents + CalendarFooter;
[Fact]
public void Parse_LiestGrundlegendesVeventKorrekt()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"DTSTAMP:20260822T195925Z\n" +
"UID:38926-2013812-2013815\n" +
"STATUS:CONFIRMED\n" +
"DTSTART;TZID=Europe/Berlin:20260817T075000\n" +
"DTEND;TZID=Europe/Berlin:20260817T092000\n" +
"SUMMARY:SOL\n" +
"LOCATION:Medien\n" +
"DESCRIPTION:10c HED\n" +
"END:VEVENT\n");
var events = IcsParser.Parse(ics);
var evt = Assert.Single(events);
Assert.Equal("38926-2013812-2013815", evt.Uid);
Assert.Equal(new DateOnly(2026, 8, 17), evt.Date);
Assert.Equal(new TimeOnly(7, 50), evt.StartTime);
Assert.Equal(new TimeOnly(9, 20), evt.EndTime);
Assert.Equal("SOL", evt.Summary);
Assert.Equal("Medien", evt.Location);
Assert.Equal("10c HED", evt.Description);
Assert.Equal("CONFIRMED", evt.Status);
}
[Fact]
public void Parse_LiestMehrereVeventsUnabhaengigVoneinander()
{
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART;TZID=Europe/Berlin:20260817T075000\nSUMMARY:NAT\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:2\nDTSTART;TZID=Europe/Berlin:20260818T094000\nSUMMARY:Mat_E\nEND:VEVENT\n");
var events = IcsParser.Parse(ics);
Assert.Equal(2, events.Count);
Assert.Equal(["1", "2"], events.Select(e => e.Uid));
Assert.Equal(["NAT", "Mat_E"], events.Select(e => e.Summary));
}
[Fact]
public void Parse_EventOhneSummary_SummaryBleibtNull()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"UID:38229-1957614\n" +
"DTSTART;TZID=Europe/Berlin:20260818T092000\n" +
"DTEND;TZID=Europe/Berlin:20260818T094000\n" +
"LOCATION:Zwingli\n" +
"DESCRIPTION:HED\n" +
"END:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Null(evt.Summary);
Assert.Equal("HED", evt.Description);
}
[Fact]
public void Parse_EscapedSemikolaInDescription_WirdAufgeloest()
{
var ics = Wrap(
"BEGIN:VEVENT\n" +
"UID:38806-2002907-2002910\n" +
"DTSTART;TZID=Europe/Berlin:20260818T094000\n" +
"SUMMARY:Mat_E\n" +
"DESCRIPTION:10a\\; 10b\\; 10c\\; Gastro HED\n" +
"END:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("10a; 10b; 10c; Gastro HED", evt.Description);
}
[Fact]
public void Parse_UtcZeitstempelMitZ_WirdNachEuropeBerlinKonvertiert()
{
// 20260817T055000Z UTC = 07:50 Europe/Berlin im Sommer (UTC+2) — defensiver Fall,
// im echten WebUntis-Export nicht beobachtet (der nutzt durchgehend TZID=Europe/Berlin).
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART:20260817T055000Z\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal(new DateOnly(2026, 8, 17), evt.Date);
Assert.Equal(new TimeOnly(7, 50), evt.StartTime);
}
[Fact]
public void Parse_StatusCancelled_WirdUebernommen()
{
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nSTATUS:CANCELLED\nDTSTART;TZID=Europe/Berlin:20260817T075000\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("CANCELLED", evt.Status);
}
[Fact]
public void Parse_VeventOhneUidOderOhneDtstart_WirdUebersprungen()
{
var ics = Wrap(
"BEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20260817T075000\nSUMMARY:OhneUid\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:ohne-dtstart\nSUMMARY:X\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:defektesDatum\nDTSTART;TZID=Europe/Berlin:keinDatum\nEND:VEVENT\n" +
"BEGIN:VEVENT\nUID:gueltig\nDTSTART;TZID=Europe/Berlin:20260817T075000\nEND:VEVENT\n");
var events = IcsParser.Parse(ics);
var evt = Assert.Single(events);
Assert.Equal("gueltig", evt.Uid);
}
[Fact]
public void Parse_GefalteteFortsetzungszeile_WirdAngehaengt()
{
// RFC-5545 Line-Folding: Fortsetzungszeile beginnt mit einem Leerzeichen.
var ics = Wrap(
"BEGIN:VEVENT\nUID:1\nDTSTART;TZID=Europe/Berlin:20260817T075000\n" +
"DESCRIPTION:10a\\; 10b\\; \n 10c\\; Gastro HED\nEND:VEVENT\n");
var evt = Assert.Single(IcsParser.Parse(ics));
Assert.Equal("10a; 10b; 10c; Gastro HED", evt.Description);
}
[Fact]
public void Parse_LeererText_LiefertLeereListe()
{
Assert.Empty(IcsParser.Parse(""));
}
}
+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);
}
}
@@ -0,0 +1,248 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using Xunit;
namespace LehrerApp.Tests;
public sealed class UntisMatchingServiceTests
{
private static PeriodScheduleService BuildPeriodSchedule()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
service.SetPeriods(
[
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(9, 20) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 40), End = new TimeOnly(11, 10) },
]);
return service;
}
// Realistische Einzelstunden (45 min) statt der bereits 90 Minuten langen Stunde 1 aus
// BuildPeriodSchedule() - für die Doppelstunden-Tests, die zwei aufeinanderfolgende einzelne
// Stunden von einem einzigen, zusammengefassten WebUntis-Termin abgedeckt sehen wollen.
private static PeriodScheduleService BuildPeriodScheduleWithSinglePeriods()
{
using var temp = new TempAppData();
var service = new PeriodScheduleService(temp.Path);
service.SetPeriods(
[
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 40), End = new TimeOnly(10, 25) },
]);
return service;
}
private static UntisIcsEvent Event(DayOfWeek weekday, TimeOnly start, TimeOnly end, string? summary,
string description, string uid = "1")
{
// Montag = 18.08.2026, Mittwoch = 20.08.2026 usw. - beliebiger Anker, nur der Wochentag zählt.
var anchor = new DateOnly(2026, 8, 17); // Montag
var offset = ((int)weekday - (int)anchor.DayOfWeek + 7) % 7;
return new UntisIcsEvent
{
Uid = uid, Date = anchor.AddDays(offset), StartTime = start, EndTime = end,
Summary = summary, Description = description,
};
}
[Fact]
public void BuildMatches_EindeutigeKlasse_WirdSicherZugeordnet()
{
var group = new LearningGroup { Name = "10c" };
var events = Enumerable.Range(0, 5)
.Select(i => Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", $"u{i}"))
.ToList();
var result = new UntisMatchingService().BuildMatches(events, [group], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(1, match.PeriodNumber);
Assert.Equal(group.Id, match.SuggestedGroupId);
Assert.True(match.IsConfident);
}
[Fact]
public void BuildMatches_NimmtHaeufigstesMusterProSlot()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "a"),
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "b"),
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED", "c"),
// Eine einzelne Abweichung (z.B. bereits eine Vertretung) darf das Muster nicht kippen.
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "NAT", "10d HED", "d"),
};
var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(groupA.Id, match.SuggestedGroupId);
Assert.Equal(3, match.Pattern.OccurrenceCount);
}
[Fact]
public void BuildMatches_KombinierteKlassen_BevorzugtGruppeMitVorhandenemSlot()
{
var groupA = new LearningGroup { Name = "10a" };
var groupB = new LearningGroup { Name = "10b" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Wednesday, new TimeOnly(9, 40), new TimeOnly(11, 10), "Mat_E", "10a; 10b HED"),
};
var existingSlot = new TimetableSlot { GroupId = groupB.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 3 };
var result = new UntisMatchingService().BuildMatches(events, [groupA, groupB], [existingSlot], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(groupB.Id, match.SuggestedGroupId);
Assert.True(match.IsConfident);
}
[Fact]
public void BuildMatches_KeinePassendeGruppe_BleibtUnbestaetigt()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "9z HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [new LearningGroup { Name = "10c" }], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Null(match.SuggestedGroupId);
Assert.False(match.IsConfident);
}
[Fact]
public void BuildMatches_TermineOhneKlassenanteil_WerdenNichtAlsMusterMitGruppeGefuehrt()
{
// Nur Lehrkraft-Kürzel in DESCRIPTION (z.B. Aufsicht/Springstunde) - kein Klassenbezug.
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(9, 20), new TimeOnly(9, 40), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [new LearningGroup { Name = "10c" }], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Null(match.SuggestedGroupId);
Assert.Empty(match.Pattern.ClassTokens);
Assert.True(match.IsSupervisionCandidate);
Assert.Null(match.PeriodNumber);
}
[Fact]
public void BuildMatches_AufsichtOhneKlassenbezug_LoestPauseNachVorherigerStundeAuf()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten, die nicht zugeordnet werden
// können. Vom Zeitraster und von der Dauer her, könnten die erfasst werden" - anders als
// eine Unterrichtsstunde (exakte Startzeit) muss eine Aufsicht IMMER auflösbar sein, auch
// wenn sie mitten in einer Pause liegt statt exakt auf einer konfigurierten Stundenzeit.
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(9, 25), new TimeOnly(9, 35), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.True(match.IsSupervisionCandidate);
Assert.Equal(1, match.AfterPeriod); // Pause direkt nach der 1. Stunde (Ende 9:20)
}
[Fact]
public void BuildMatches_AufsichtVorDerErstenStunde_LoestAfterPeriodNullAuf()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Tuesday, new TimeOnly(7, 0), new TimeOnly(7, 40), null, "HED"),
};
var result = new UntisMatchingService().BuildMatches(events, [], [], BuildPeriodSchedule());
var match = Assert.Single(result.Matches);
Assert.Equal(0, match.AfterPeriod);
}
[Fact]
public void BuildMatches_TimetableSlotOhnePassendesMuster_LandetInUnmatchedList()
{
var group = new LearningGroup { Name = "10c" };
var orphanSlot = new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Friday, PeriodNumber = 3 };
var result = new UntisMatchingService().BuildMatches([], [group], [orphanSlot], BuildPeriodSchedule());
Assert.Empty(result.Matches);
Assert.Contains(orphanSlot, result.UnmatchedTimetableSlots);
}
// ── Doppelstunden (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind") ─────────────────────
//
// WebUntis meldet eine Doppelstunde als EINEN Termin über beide Stundenzeiten hinweg, während
// der Stundenplan der App dafür zwei TimetableSlot-Einträge (eine Stunde je Slot) haben kann.
[Fact]
public void BuildMatches_Doppelstunde_LoestBeideStundennummernAlsCoveredPeriodsAuf()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED"),
};
var result = new UntisMatchingService().BuildMatches(
events, [new LearningGroup { Name = "10c" }], [], BuildPeriodScheduleWithSinglePeriods());
var match = Assert.Single(result.Matches);
Assert.Equal([1, 2], match.CoveredPeriods);
Assert.Equal(1, match.PeriodNumber);
}
[Fact]
public void BuildMatches_Doppelstunde_BestaetigtBeideTimetableSlotsAlsZugeordnet()
{
var group = new LearningGroup { Name = "10c" };
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(7, 50), new TimeOnly(9, 20), "SOL", "10c HED"),
};
var timetableSlots = new List<TimetableSlot>
{
new() { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 },
new() { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 },
};
var result = new UntisMatchingService().BuildMatches(
events, [group], timetableSlots, BuildPeriodScheduleWithSinglePeriods());
// Beide Stunden der Doppelstunde gelten als zugeordnet, nicht nur die erste.
Assert.Empty(result.UnmatchedTimetableSlots);
}
[Fact]
public void BuildMatches_EinzelneStunde_UeberdecktNurGenauEinePeriode()
{
var events = new List<UntisIcsEvent>
{
Event(DayOfWeek.Monday, new TimeOnly(9, 40), new TimeOnly(10, 25), "NAT", "6a HED"),
};
var result = new UntisMatchingService().BuildMatches(
events, [new LearningGroup { Name = "6a" }], [], BuildPeriodScheduleWithSinglePeriods());
Assert.Equal([3], Assert.Single(result.Matches).CoveredPeriods);
}
private sealed class TempAppData : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-untismatching-tests-{Guid.NewGuid():N}");
public TempAppData() => Directory.CreateDirectory(Path);
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
}
}