diff --git a/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs index 64f709b..02e37e4 100644 --- a/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs @@ -14,7 +14,7 @@ public sealed class TimetableSlotDialogViewModelTests { var slots = new FakeTimetableSlots(); var groups = new FakeGroups([]); - var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null); + var vm = new TimetableSlotDialogViewModel(slots, groups, new FakeSubjects([]), SchoolYear(), DayOfWeek.Monday, 1, null); vm.SaveCommand.Execute(null); @@ -28,7 +28,7 @@ public sealed class TimetableSlotDialogViewModelTests var group = new LearningGroup { Name = "Q1 Chemie" }; var slots = new FakeTimetableSlots(); var groups = new FakeGroups([group]); - var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null) + var vm = new TimetableSlotDialogViewModel(slots, groups, new FakeSubjects([]), SchoolYear(), DayOfWeek.Monday, 1, null) { SelectedGroupName = "Q1 Chemie", Room = "R204", }; @@ -49,7 +49,7 @@ public sealed class TimetableSlotDialogViewModelTests var slots = new FakeTimetableSlots(); slots.Add(new TimetableSlot { GroupId = groupA.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); var groups = new FakeGroups([groupA, groupB]); - var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, null) + var vm = new TimetableSlotDialogViewModel(slots, groups, new FakeSubjects([]), SchoolYear(), DayOfWeek.Monday, 1, null) { SelectedGroupName = "Q1 Physik", }; @@ -68,11 +68,60 @@ public sealed class TimetableSlotDialogViewModelTests var slots = new FakeTimetableSlots(); slots.Add(slot); var groups = new FakeGroups([group]); - var vm = new TimetableSlotDialogViewModel(slots, groups, SchoolYear(), DayOfWeek.Monday, 1, slot); + var vm = new TimetableSlotDialogViewModel(slots, groups, new FakeSubjects([]), SchoolYear(), DayOfWeek.Monday, 1, slot); vm.DeleteCommand.Execute(null); Assert.True(vm.Deleted); Assert.Empty(slots.GetAll()); } + + /// Regressionstest für den vom Nutzer gemeldeten Absturz: dieselbe Klasse ("10c") wird in + /// zwei Fächern unterrichtet und hat deshalb zwei LearningGroup-Datensätze mit demselben + /// Namen — das Anlegen der Gruppenliste darf dabei nicht mehr mit einer ArgumentException + /// abstürzen, und beide Gruppen müssen über eindeutige, unterscheidbare Labels wählbar bleiben. + [Fact] + public void KonstruktorMitGleichnamigenGruppenInVerschiedenenFaechern_StuerztNichtAbUndBleibtUnterscheidbar() + { + var chemie = new Subject { Name = "Chemie" }; + var mathe = new Subject { Name = "Mathematik" }; + var groupChemie = new LearningGroup { Name = "10c", SubjectId = chemie.Id }; + var groupMathe = new LearningGroup { Name = "10c", SubjectId = mathe.Id }; + var groups = new FakeGroups([groupChemie, groupMathe]); + var subjects = new FakeSubjects([chemie, mathe]); + var slots = new FakeTimetableSlots(); + + var vm = new TimetableSlotDialogViewModel(slots, groups, subjects, SchoolYear(), DayOfWeek.Monday, 1, null); + + Assert.Equal(2, vm.GroupOptions.Length); + Assert.Equal(vm.GroupOptions.Distinct().Count(), vm.GroupOptions.Length); + Assert.Contains("10c (Chemie)", vm.GroupOptions); + Assert.Contains("10c (Mathematik)", vm.GroupOptions); + + vm.SelectedGroupName = "10c (Mathematik)"; + vm.SaveCommand.Execute(null); + + Assert.NotNull(vm.Result); + Assert.Equal(groupMathe.Id, vm.Result!.GroupId); + } + + /// Beim Bearbeiten eines bestehenden Slots muss das vorausgewählte Label zur tatsächlich + /// zugewiesenen Gruppe passen, auch wenn deren Name mehrdeutig ist. + [Fact] + public void KonstruktorBeimBearbeiten_WaehltDasLabelDerTatsaechlichZugewiesenenGruppeVor() + { + var chemie = new Subject { Name = "Chemie" }; + var mathe = new Subject { Name = "Mathematik" }; + var groupChemie = new LearningGroup { Name = "10c", SubjectId = chemie.Id }; + var groupMathe = new LearningGroup { Name = "10c", SubjectId = mathe.Id }; + var groups = new FakeGroups([groupChemie, groupMathe]); + var subjects = new FakeSubjects([chemie, mathe]); + var slot = new TimetableSlot { GroupId = groupChemie.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }; + var slots = new FakeTimetableSlots(); + slots.Add(slot); + + var vm = new TimetableSlotDialogViewModel(slots, groups, subjects, SchoolYear(), DayOfWeek.Monday, 1, slot); + + Assert.Equal("10c (Chemie)", vm.SelectedGroupName); + } } diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs index 8b5ef8f..727093d 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableSlotDialogViewModel.cs @@ -10,7 +10,7 @@ namespace LehrerApp.Desktop.ViewModels.Planning; public partial class TimetableSlotDialogViewModel : ObservableObject { private readonly ITimetableSlotRepository _slots; - private readonly Dictionary _groupIdsByName; + private readonly Dictionary _groupIdsByLabel; private readonly TimetableSlot? _editing; public DayOfWeek Weekday { get; } @@ -30,7 +30,8 @@ public partial class TimetableSlotDialogViewModel : ObservableObject public bool Deleted { get; private set; } public TimetableSlotDialogViewModel(ITimetableSlotRepository slots, IGroupRepository groups, - SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, TimetableSlot? editing) + ISubjectRepository subjects, SchoolYearService schoolYear, DayOfWeek weekday, int periodNumber, + TimetableSlot? editing) { _slots = slots; _editing = editing; @@ -45,12 +46,31 @@ public partial class TimetableSlotDialogViewModel : ObservableObject DialogTitle = $"{WeekdayLabel}, {periodNumber}. Stunde"; var availableGroups = groups.GetBySchoolYear(schoolYear.CurrentSchoolYear()).OrderBy(g => g.Name).ToList(); - _groupIdsByName = availableGroups.ToDictionary(g => g.Name, g => g.Id); - GroupOptions = availableGroups.Select(g => g.Name).ToArray(); + var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name); + var nameCounts = availableGroups.GroupBy(g => g.Name).ToDictionary(g => g.Key, g => g.Count()); + + // Der Gruppenname allein ist nicht eindeutig: dieselbe Klasse in mehreren Fächern hat + // mehrere LearningGroup-Datensätze mit demselben Namen. Bei einem Namenskonflikt wird + // deshalb das Fach angehängt ("10c (Chemie)") — ohne diese Absicherung würde die + // Dictionary-Befüllung unten mit einer ArgumentException abstürzen (echter Nutzer-Fehler). + _groupIdsByLabel = new Dictionary(); + var labeledGroups = new List<(string Label, Guid Id)>(); + foreach (var g in availableGroups) + { + var label = nameCounts[g.Name] > 1 + ? $"{g.Name} ({(g.SubjectId is { } sid && subjectNames.TryGetValue(sid, out var sn) ? sn : "ohne Fach")})" + : g.Name; + var uniqueLabel = label; + var suffix = 2; + while (_groupIdsByLabel.ContainsKey(uniqueLabel)) uniqueLabel = $"{label} ({suffix++})"; + _groupIdsByLabel[uniqueLabel] = g.Id; + labeledGroups.Add((uniqueLabel, g.Id)); + } + GroupOptions = labeledGroups.Select(x => x.Label).ToArray(); if (editing is not null) { - SelectedGroupName = availableGroups.FirstOrDefault(g => g.Id == editing.GroupId)?.Name ?? ""; + SelectedGroupName = labeledGroups.FirstOrDefault(x => x.Id == editing.GroupId).Label ?? ""; Room = editing.Room ?? ""; } } @@ -59,7 +79,7 @@ public partial class TimetableSlotDialogViewModel : ObservableObject private void Save() { GroupError = ""; - if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByName.TryGetValue(SelectedGroupName, out var groupId)) + if (string.IsNullOrWhiteSpace(SelectedGroupName) || !_groupIdsByLabel.TryGetValue(SelectedGroupName, out var groupId)) { GroupError = "Bitte eine Gruppe auswählen."; return; diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml.cs b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml.cs index 6efd144..aa938cc 100644 --- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml.cs +++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml.cs @@ -28,6 +28,7 @@ public partial class TimetableView : UserControl var vm = new TimetableSlotDialogViewModel( App.Services.GetRequiredService(), App.Services.GetRequiredService(), + App.Services.GetRequiredService(), App.Services.GetRequiredService(), cell.Weekday.Value, cell.PeriodNumber, cell.Slot); diff --git a/TODO.md b/TODO.md index 8d1f4d5..a4079ce 100644 --- a/TODO.md +++ b/TODO.md @@ -472,6 +472,18 @@ Redesign: umgesetzt:** Klick deckt die vollständige Bearbeitung (Zuweisen/Ändern/Entfernen) bereits ab: Drag wäre nur eine schnellere Geste für "Zuordnung an eine andere Zelle verschieben", kein zusätzlicher Funktionsumfang — bei Bedarf später ergänzbar. + + **Nachtrag (Bugfix, aus echtem Nutzungsfall):** `TimetableSlotDialogViewModel` baute die + Gruppenauswahl für den Zuweisen-Dialog als `Dictionary`, geschlüsselt nach + `LearningGroup.Name` — bei zwei Lerngruppen mit demselben Namen (dieselbe Klasse in zwei + Fächern unterrichtet, z.B. zwei Datensätze "10c") warf `ToDictionary` eine + `ArgumentException` ("An item with the same key has already been added"), der Dialog ließ + sich gar nicht mehr öffnen. Behoben durch eindeutige Anzeige-Labels statt des rohen Namens: + bei einem Namenskonflikt wird das Fach angehängt ("10c (Chemie)" vs. "10c (Mathematik)"), + mit einem nummerierten Fallback-Suffix für den theoretischen Restfall gleicher Name UND + gleiches Fach. Zwei Regressionstests in + [TimetableSlotDialogViewModelTests.cs](LehrerApp.Desktop.Tests/TimetableSlotDialogViewModelTests.cs) + bilden genau dieses Szenario nach. - [x] **4.3.4** Abgleich mit `LearningGroup.HoursPerWeek` — Seitenleiste "Abweichende Wochenstunden" listet jede Gruppe der aktuellen Schuljahres, deren eingetragene Slot-Anzahl nicht der hinterlegten Wochenstundenzahl entspricht.