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(""));
}
}