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
+27
View File
@@ -31,6 +31,14 @@ public static class TestSupport
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static WebUntisSettingsService BuildWebUntisSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new WebUntisSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService()
{
@@ -422,10 +430,29 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSnapshots : IUntisSnapshotRepository
{
private readonly List<UntisSnapshotEntry> _all = [];
public void Add(UntisSnapshotEntry e) => _all.Add(e);
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
{
private readonly List<UntisSlotMapping> _all = [];
public void Add(UntisSlotMapping m) => _all.Add(m);
public List<UntisSlotMapping> GetAll() => _all.ToList();
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
}
public class FakeWorkTasks : IWorkTaskRepository
{
private readonly List<WorkTask> _all = [];
@@ -34,6 +34,7 @@ public sealed class SettingsViewModelTests
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
@@ -289,6 +290,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -314,6 +316,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -343,6 +346,7 @@ public sealed class SettingsViewModelTests
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
@@ -12,7 +13,8 @@ public sealed class TimetableViewModelTests
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null)
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -25,7 +27,8 @@ public sealed class TimetableViewModelTests
holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries());
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
}
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -727,6 +730,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_ZusaetzlicheAufsichtInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" - dieselbe Anzeige-Infrastruktur wie beim Ausfall oben, hier für
// eine automatisch erkannte zusätzliche Aufsicht ohne reguläre SupervisionDuty.
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Monday).AddDays(7);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Supervision, AfterPeriod = 1,
Description = "Automatisch über WebUntis-Abgleich erkannt: Zusätzliche Aufsicht laut WebUntis.",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
Assert.DoesNotContain(vm.WeekItems, c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
vm.NextWeekCommand.Execute(null);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.True(mondayCell.IsSubstitutionSupervision);
Assert.Contains("Zusätzliche Aufsicht", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
{
@@ -930,6 +958,38 @@ public sealed class TimetableViewModelTests
Assert.Equal("6a auf Klassenfahrt", cell.Topic);
}
[Fact]
public void Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Nächste Woche habe ich eine Stunde Ausfall [...] wäre es doch auch
// schön, wenn das im Stundenplan für die nächste Woche irgendwie erkenntlich ist" - genau
// der Weg, den ein automatisch von WebUntis erkannter Ausfall nimmt (derselbe
// SubstitutionEntry-Mechanismus wie bei Handeinträgen), hier erstmals mit WeekOffset != 0
// geprüft.
var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" };
var group = new LearningGroup { Name = "6a", SubjectId = subject.Id };
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Thursday).AddDays(7);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Thursday, PeriodNumber = 3 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Cancelled, PeriodNumber = 3,
Description = "Automatisch über WebUntis-Abgleich erkannt.",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions);
// Aktuelle Woche: an dieser Stelle regulär, noch kein Ausfall sichtbar.
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.False(currentWeekCell.IsCancelled);
vm.NextWeekCommand.Execute(null);
var nextWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.True(nextWeekCell.IsCancelled);
Assert.Equal("Automatisch über WebUntis-Abgleich erkannt.", nextWeekCell.Topic);
}
[Fact]
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
{
@@ -1041,4 +1101,79 @@ public sealed class TimetableViewModelTests
Assert.Single(vm.UpcomingExams);
}
// ── WebUntis-Abweichung ───────────────────────────────────────────────────
[Fact]
public void Load_UntisAbgleichNichtAktiviert_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_UntisAktivSlotOhneBestaetigteZuordnung_ZeigtAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisSettings: untisSettings);
Assert.True(vm.HasUntisMismatch);
Assert.Contains("1 Stundenplan-Eintrag", vm.UntisMismatchLabel);
}
[Fact]
public void Load_UntisAktivMitBestaetigterZuordnung_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_DoppelstundenZuordnungBestaetigtBeideTimetableSlots()
{
// Regression: "ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
// Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" - eine WebUntis-Doppelstunde
// bestätigt beide zugehörigen TimetableSlots, nicht nur den ersten.
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, CoveredPeriods = [1, 2],
GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisMappingReviewDialogViewModelTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untisreview-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
private static UntisSyncService BuildUntisSyncService(FakeUntisSlotMappings mappings) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
new FakeUntisSnapshots(), mappings, new FakeSubstitutionEntries(), new FakeGroups([]),
new FakeTimetableSlots(), new FakeSupervisionDuties(), new FakeSchoolHolidays(),
new PublicHolidayService(), new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static UntisMappingReviewDialogViewModel BuildDialogVm(FakeUntisSlotMappings mappings,
List<LearningGroup>? groups = null) =>
new(BuildUntisSyncService(mappings), mappings, groups ?? [], new FakeSubjects([]));
private static List<UntisGroupOption> Options(params LearningGroup[] groups) =>
groups.Select(g => new UntisGroupOption(g, null)).ToList();
private static UntisSlotMatch BuildLessonMatch(Guid? groupId, string classToken = "10c") => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", ClassTokens = [classToken], OccurrenceCount = 40,
},
PeriodNumber = 1, SuggestedGroupId = groupId, IsConfident = groupId is not null,
};
private static UntisSlotMatch BuildSupervisionMatch() => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), EndTime = new TimeOnly(9, 40),
Summary = null, ClassTokens = [], OccurrenceCount = 40,
},
AfterPeriod = 2,
};
[Fact]
public void UntisMappingRow_VorschlagWirdVorausgewaehlt()
{
var group = new LearningGroup { Name = "10c" };
var row = new UntisMappingRow(BuildLessonMatch(group.Id), Options(group), existing: null);
Assert.Equal(group.Id, row.SelectedGroup?.Group.Id);
Assert.True(row.IsConfident);
Assert.False(row.IsSupervisionCandidate);
}
[Fact]
public void UntisMappingRow_OhneVorschlag_BleibtLeer()
{
var row = new UntisMappingRow(BuildLessonMatch(null), [], existing: null);
Assert.Null(row.SelectedGroup);
Assert.False(row.IsConfident);
}
[Fact]
public void UntisGroupOption_GleicherNameVerschiedeneFaecher_ZeigtFachImDisplayLabel()
{
// Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das Fach dabei, kann ich nicht
// sicher die richtige Lerngruppe hier auswählen."
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, "Chemie");
Assert.Equal("10c (Chemie)", option.DisplayLabel);
}
[Fact]
public void UntisGroupOption_OhneFach_ZeigtNurDenNamen()
{
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, null);
Assert.Equal("10c", option.DisplayLabel);
}
[Fact]
public void UntisMappingRow_AufsichtsMuster_WirdAlsSupervisionCandidateErkanntUndKannAufgeloestWerden()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten [...] vom Zeitraster und von der
// Dauer her könnten die erfasst werden" - kein Klassenbezug, aber AfterPeriod auflösbar.
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null);
Assert.True(row.IsSupervisionCandidate);
Assert.True(row.CanResolve);
Assert.False(row.ConfirmAsSupervision);
}
[Fact]
public void Save_SchreibtNurZeilenMitAusgewaehlterGruppeAlsBestaetigt()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(null, "9z"), Options(groupA, groupB), existing: null)); // ignoriert
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.True(saved.Confirmed);
Assert.Equal(SubstitutionKind.Lesson, saved.Kind);
Assert.Equal(groupA.Id, saved.GroupId);
Assert.Equal(1, saved.PeriodNumber);
}
[Fact]
public void Save_AufsichtBestaetigt_SchreibtSupervisionMapping()
{
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings);
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null) { ConfirmAsSupervision = true };
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(SubstitutionKind.Supervision, saved.Kind);
Assert.Null(saved.GroupId);
Assert.Equal(2, saved.AfterPeriod);
Assert.True(saved.Confirmed);
}
[Fact]
public void Save_ErneutesSpeichernDerselbenZeile_AktualisiertStattDupliziert()
{
// Regression für "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" -
// wiederholtes Bestätigen desselben Slots darf keine zweite Zeile anlegen (sonst
// ArgumentException in UntisDiffService.Diff beim nächsten Poll, siehe dortigen Test).
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm1 = BuildDialogVm(mappings, [groupA, groupB]);
vm1.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm1.SaveCommand.Execute(null);
var firstSaved = Assert.Single(mappings.GetAll());
// Dialog erneut geöffnet - die bereits bestätigte Gruppe muss vorbefüllt sein.
var existing = mappings.GetAll().Single();
var row2 = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing);
Assert.Equal(groupA.Id, row2.SelectedGroup?.Group.Id);
row2.SelectedGroup = Options(groupB).Single(); // Nutzer korrigiert
var vm2 = BuildDialogVm(mappings, [groupA, groupB]);
vm2.Rows.Add(row2);
vm2.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(firstSaved.Id, saved.Id);
Assert.Equal(groupB.Id, saved.GroupId);
}
[Fact]
public void Save_NutzerAendertVorschlag_UebernimmtNeueAuswahl()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
var row = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null);
row.SelectedGroup = Options(groupB).Single();
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
Assert.Equal(groupB.Id, Assert.Single(mappings.GetAll()).GroupId);
}
}
@@ -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);
}
}
@@ -0,0 +1,89 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisSettingsServiceTests
{
private const string SampleUrl = "https://example.webuntis.com/WebUntis/ical_export?school=example&id=test&elemId=1&token=geheim";
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void SetEnabled_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetEnabled(true);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.Enabled);
}
[Fact]
public void SetIcalUrl_IstVerschluesseltAbrufbar()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
[Fact]
public void IcalUrl_UeberlebtNeueInstanzMitDemselbenPfad()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetIcalUrl(SampleUrl);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.IsConfigured);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
}
[Fact]
public void KonfigurationsdateiEnthaeltNichtDasTokenImKlartext()
{
var path = BuildTempPath();
var service = new WebUntisSettingsService(path);
service.SetIcalUrl(SampleUrl);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
Assert.DoesNotContain("token=geheim", raw);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.Null(service.GetIcalUrl());
Assert.False(service.Enabled);
}
[Fact]
public void SetLastSync_PersistiertZeitstempelUndStatus()
{
var path = BuildTempPath();
var at = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
new WebUntisSettingsService(path).SetLastSync(at, "3 Vertretungen erkannt");
var reloaded = new WebUntisSettingsService(path);
Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
}
}