From 888df39f3780923f954a79de0ce6a8eb41688d49 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 17 Aug 2026 00:27:14 +0200 Subject: [PATCH] Fix: ArgumentException im Stundenplan bei gleichnamigen Lerngruppen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimetableSlotDialogViewModel baute die Gruppenauswahl als Dictionary, geschlüsselt nach LearningGroup.Name. Bei zwei Lerngruppen mit demselben Namen (dieselbe Klasse in zwei Fächern unterrichtet, z.B. zwei "10c") warf ToDictionary eine ArgumentException, der Zuweisen-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 nummeriertem Fallback für den Restfall gleicher Name und gleiches Fach. Co-Authored-By: Claude Sonnet 5 --- .../TimetableSlotDialogViewModelTests.cs | 57 +++++++++++++++++-- .../Planning/TimetableSlotDialogViewModel.cs | 32 +++++++++-- .../Views/Planning/TimetableView.axaml.cs | 1 + TODO.md | 12 ++++ 4 files changed, 92 insertions(+), 10 deletions(-) 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.