Files
LehrerApp/LehrerApp.Tests/UntisMatchingServiceTests.cs
T
adminandClaude Sonnet 5 4b9bcfe85d fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen
- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:36 +02:00

272 lines
12 KiB
C#

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_KombinierteKlassenKommaGetrennt_WerdenEbenfallsAufgesplittet()
{
// Nicht empirisch belegt, ob WebUntis mehrere Klassen mit ";" oder "," trennt (das einzige
// real verifizierte Beispiel hatte nur eine Klasse) - ExtractClassTokens darf sich deshalb
// nicht auf ";" verlassen, sonst landet "10a, 10b, 10c" als EIN Token mit Leerzeichen statt
// drei getrennten (siehe UntisDiffServiceTests: Diff_KombinierteGruppeMitKommaGetrenntemClassToken...).
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(["10a", "10b"], match.Pattern.ClassTokens);
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); }
}
}