- 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>
272 lines
12 KiB
C#
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); }
|
|
}
|
|
}
|