Stundenplan und Untis-Namensabgleich verbessern
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-25 11:54:51 +02:00
1 parent 45b7a2b7e6
commit 960d52686a
8 files changed
+98 -51

No files matched your search

@@ -64,6 +64,44 @@ public sealed class TimetableViewModelTests
Assert.Equal(6 + 10 * 6, vm.WeekItems.Count);
}
[Fact]
public void Load_MarkiertDieAktuelleStundeAusDemHinterlegtenStundenraster()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-currentperiod-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var schedule = new PeriodScheduleService(tempPath);
schedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) },
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 0), End = new TimeOnly(9, 45) },
]);
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), periodSchedule: schedule);
vm.Clock = () => new DateTime(2026, 9, 21, 9, 15, 0); // Montag, 3. Stunde
vm.Load();
var periodLabels = vm.WeekItems.Where(x => x.IsPeriodLabel).ToList();
Assert.True(periodLabels.Single(x => x.PeriodNumber == 3).IsCurrentPeriod);
Assert.DoesNotContain(periodLabels, x => x.PeriodNumber != 3 && x.IsCurrentPeriod);
}
[Fact]
public void Load_InAndererKalenderwoche_MarkiertKeineAktuelleStunde()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-currentperiod-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var schedule = new PeriodScheduleService(tempPath);
schedule.SetPeriods([
new PeriodTimeEntry { PeriodNumber = 3, Start = new TimeOnly(9, 0), End = new TimeOnly(9, 45) },
]);
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), periodSchedule: schedule);
vm.Clock = () => new DateTime(2026, 9, 21, 9, 15, 0);
vm.WeekOffset = 1;
vm.Load();
Assert.DoesNotContain(vm.WeekItems, x => x.IsCurrentPeriod);
}
[Fact]
public void Load_ZeigtZugewieseneGruppeInDerPassendenZelle()
{
@@ -9,6 +9,8 @@ public sealed class UntisNameMatchingTests
[InlineData("Ben Schmidt", "Schmidt Ben")]
[InlineData("Ada Müller", "MÜLLER ADA")]
[InlineData("Max Mustermann", "mustermann max")]
[InlineData("Gerste, Amelia", "Gerste Amelia")]
[InlineData("Müller-Ada", "Ada Müller")]
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
Assert.True(UntisNameMatching.NamesMatch(a, b));
@@ -19,17 +21,11 @@ public sealed class UntisNameMatchingTests
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
Assert.False(UntisNameMatching.NamesMatch(a, b));
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma), während WebUntis
// denselben Namen je nach Bericht ohne Komma und in wechselnder Reihenfolge liefert.
[Fact]
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
public void NamesMatch_KommaGetrennterAnzeigename_PasstZumUntisNamenOhneKomma()
{
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
Assert.True(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
}
}
@@ -16,7 +16,8 @@ public static class UntisNameMatching
!string.IsNullOrWhiteSpace(a) && !string.IsNullOrWhiteSpace(b) && NameKey(a) == NameKey(b);
public static string NameKey(string value) => string.Join(' ',
value.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Trim().ToLowerInvariant())
System.Text.RegularExpressions.Regex.Split(value, @"[^\p{L}\p{Nd}]+")
.Where(w => w.Length > 0)
.Select(w => w.ToLowerInvariant())
.OrderBy(w => w, StringComparer.Ordinal));
}
@@ -6,6 +6,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -130,7 +131,7 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var nameKey = UntisNameMatching.NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
@@ -209,17 +210,11 @@ public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
return null;
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
// innerhalb der Kandidaten eindeutig.
// Wie bei den übrigen WebUntis-Abgleichen: Reihenfolge und Satzzeichen werden normalisiert,
// aber nur innerhalb der Kandidaten eindeutige Namen automatisch zugeordnet.
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
candidates
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
@@ -5,6 +5,7 @@ using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Groups;
@@ -122,11 +123,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
var byKey = courseStudents.Select(student => (Student: student, Key: UntisLessonAbsenceHelper.StudentExternKey(student)))
.Where(x => x.Key is not null).ToDictionary(x => x.Key!.Value, x => x.Student);
var byName = courseStudents
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
@@ -155,7 +152,7 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
{
var match = absence.ExternKey is { } key && byKey.TryGetValue(key, out var byKeyStudent)
? byKeyStudent
: byName.GetValueOrDefault(NameKey(absence.StudentName));
: byName.GetValueOrDefault(UntisNameMatching.NameKey(absence.StudentName));
var targetStatus = MapStatus(absence);
var row = new WebUntisLessonAbsenceRow
{
@@ -281,11 +278,6 @@ public partial class WebUntisLessonAbsenceComparisonViewModel : ObservableObject
return filled;
}
// Groß-/Kleinschreibung, Leerraum und - da die tatsächliche WebUntis-Reihenfolge nicht
// dokumentiert und schulabhängig unterschiedlich beobachtet wurde - beide Namensreihenfolgen
// werden beim Aufbau von `byName` registriert; hier wird nur normalisiert.
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// MapStatus/StudentKey leben jetzt in UntisLessonAbsenceHelper (framework-frei), damit
// UntisComparisonTools (MCP) exakt dieselbe Regel verwendet statt eines eigenen Duplikats, das
// aus dem Tritt geraten könnte.
@@ -164,7 +164,8 @@ public partial class TimetableViewModel : ObservableObject
public void Load()
{
var today = DateOnly.FromDateTime(Clock());
var now = Clock();
var today = DateOnly.FromDateTime(now);
TodayLabel = today.ToString("dddd, dd.MM.yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
var publicHolidayDates = new HashSet<DateOnly>();
@@ -176,7 +177,7 @@ public partial class TimetableViewModel : ObservableObject
var duties = _supervisionDuties.GetAll();
BuildGrid(holidayBadges, duties);
BuildWeekOverview(today, publicHolidayDates, duties);
BuildWeekOverview(today, now, publicHolidayDates, duties);
ApplyWeatherToWeekHeaders(today);
_ = LoadWeatherAsync(today);
BuildToday(today);
@@ -436,13 +437,17 @@ public partial class TimetableViewModel : ObservableObject
/// Vertretungsaufsicht) werden zwischen den betroffenen Stundenzeilen eingefügt, Vertretungsstunden
/// ersetzen für ihre Stunde die sonst aus dem Stundenplan abgeleitete Anzeige.
/// </summary>
private void BuildWeekOverview(DateOnly today, HashSet<DateOnly> publicHolidayDates, List<SupervisionDuty> duties)
private void BuildWeekOverview(DateOnly today, DateTime now, HashSet<DateOnly> publicHolidayDates,
List<SupervisionDuty> duties)
{
WeekItems.Clear();
var currentWeekMonday = today.AddDays(-((int)today.DayOfWeek + 6) % 7);
var monday = currentWeekMonday.AddDays(WeekOffset * 7);
var friday = monday.AddDays(4);
WeekRangeLabel = $"{monday:dd.MM.} – {friday:dd.MM.yyyy}";
var currentPeriod = monday == currentWeekMonday && Weekdays.Contains(today.DayOfWeek)
? CurrentPeriodAt(TimeOnly.FromDateTime(now))
: null;
var allSlots = _slots.GetAll();
var groups = _groups.GetAll(includeInactive: true).ToDictionary(g => g.Id);
@@ -459,7 +464,7 @@ public partial class TimetableViewModel : ObservableObject
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
WeekItems.Add(WeekCellItem.PeriodLabel(period));
WeekItems.Add(WeekCellItem.PeriodLabel(period, period == currentPeriod));
foreach (var weekday in Weekdays)
{
var date = dateByWeekday[weekday];
@@ -518,6 +523,19 @@ public partial class TimetableViewModel : ObservableObject
WeekRows.Add(new WeekRowItem(rowCells));
}
private int? CurrentPeriodAt(TimeOnly time)
{
for (var period = FirstPeriod; period <= LastPeriod; period++)
{
if (_periodSchedule.GetTimes(period) is not { } times) continue;
var within = times.End > times.Start
? time >= times.Start && time < times.End
: times.End < times.Start && (time >= times.Start || time < times.End);
if (within) return period;
}
return null;
}
private void AddWeekSupervisionRowIfAny(int afterPeriod, ILookup<int, SupervisionDuty> dutiesByPeriod,
List<SubstitutionEntry> substitutionsThisWeek, Dictionary<DayOfWeek, DateOnly> dateByWeekday)
{
@@ -845,6 +863,7 @@ public partial class WeekCellItem : ObservableObject
public DayOfWeek? Weekday { get; private init; }
public int PeriodNumber { get; private init; }
public bool IsToday { get; private init; }
public bool IsCurrentPeriod { get; private init; }
public DateOnly? Date { get; private init; }
public Guid GroupId { get; private init; }
public string SubjectLabel { get; private init; } = "";
@@ -908,7 +927,10 @@ public partial class WeekCellItem : ObservableObject
}) + $" {date:dd.MM.}",
};
public static WeekCellItem PeriodLabel(int period) => new() { IsPeriodLabel = true, Text = period.ToString() };
public static WeekCellItem PeriodLabel(int period, bool isCurrentPeriod = false) => new()
{
IsPeriodLabel = true, PeriodNumber = period, IsCurrentPeriod = isCurrentPeriod, Text = period.ToString(),
};
public static WeekCellItem Empty(DayOfWeek day, int period) => new() { Weekday = day, PeriodNumber = period };
@@ -5,6 +5,7 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
namespace LehrerApp.Desktop.ViewModels.Students;
@@ -108,7 +109,7 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
foreach (var (entry, date) in ordered)
{
var nameKey = NameKey(entry.StudentName);
var nameKey = UntisNameMatching.NameKey(entry.StudentName);
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
? classIndex.GetValueOrDefault(nameKey)
: null)
@@ -172,17 +173,11 @@ public partial class WebUntisDocumentationComparisonViewModel : ObservableObject
Status = $"{selected.Count} Einträge aus WebUntis übernommen.";
}
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
// Wie beim Fehlzeiten-Abgleich: WebUntis liefert Namen nicht einheitlich in einer Reihenfolge,
// deshalb werden beide Reihenfolgen registriert, aber nur falls innerhalb der Kandidaten eindeutig.
// Die gemeinsame Untis-Normalisierung ignoriert Reihenfolge, Komma und abweichende Leerzeichen.
// Mehrdeutige Namen bleiben weiterhin bewusst ohne automatische Vorauswahl.
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
candidates
.SelectMany(student => new[]
{
NameKey($"{student.LastName} {student.FirstName}"),
NameKey($"{student.FirstName} {student.LastName}"),
}.Select(key => (Key: key, Student: student)))
.Select(student => (Key: UntisNameMatching.NameKey(student.FullName), Student: student))
.GroupBy(x => x.Key)
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
.ToDictionary(group => group.Key, group => group.First().Student);
@@ -15,6 +15,12 @@
<Style Selector="Border.weekheader.today">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
</Style>
<Style Selector="Border.periodheader.current">
<Setter Property="Background" Value="{DynamicResource SystemAccentColor}"/>
</Style>
<Style Selector="Border.periodheader.current TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style Selector="Border.supervisioncell">
<Setter Property="Background" Value="#616161"/>
</Style>
@@ -229,9 +235,11 @@
</Border>
</StackPanel>
</Border>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsPeriodLabel}"
FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Classes="periodheader" Classes.current="{Binding IsCurrentPeriod}"
CornerRadius="4" IsVisible="{Binding IsPeriodLabel}">
<TextBlock Text="{Binding Text}" FontWeight="SemiBold" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Text}" IsVisible="{Binding IsSupervisionRow}"
FontSize="10" FontWeight="SemiBold" Opacity="0.55" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"/>