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,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(""));
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user