This commit is contained in:
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"/>
|
||||
|
||||
Reference in new issue
Block a user