From 94946ffefbdf5bafcd6122269c1b50e7b36bea24 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Wed, 2 Sep 2026 21:27:06 +0200 Subject: [PATCH] =?UTF-8?q?Stundenplan:=20verwaiste=20Stunden=20per=20"Fix?= =?UTF-8?q?It"-Button=20verkn=C3=BCpfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer (kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen" an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog sucht stattdessen nach einer passenden vorhandenen Stunde und hängt sie auf den geklickten Termin um. Co-Authored-By: Claude Sonnet 5 --- .../LessonFixItSearchTests.cs | 94 +++++++++++++++++++ .../ViewModels/Groups/PlanningViewModels.cs | 52 ++++++++++ .../Views/Groups/LessonDialog.axaml | 3 + .../Views/Groups/LessonDialog.axaml.cs | 57 +++++++++++ .../Groups/LinkExistingLessonDialog.axaml | 38 ++++++++ .../Groups/LinkExistingLessonDialog.axaml.cs | 21 +++++ 6 files changed, 265 insertions(+) create mode 100644 LehrerApp.Desktop.Tests/LessonFixItSearchTests.cs create mode 100644 LehrerApp.Desktop/Views/Groups/LinkExistingLessonDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/LinkExistingLessonDialog.axaml.cs diff --git a/LehrerApp.Desktop.Tests/LessonFixItSearchTests.cs b/LehrerApp.Desktop.Tests/LessonFixItSearchTests.cs new file mode 100644 index 0000000..e8182a8 --- /dev/null +++ b/LehrerApp.Desktop.Tests/LessonFixItSearchTests.cs @@ -0,0 +1,94 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen- +/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im +/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht). +public class LessonFixItSearchTests +{ + [Fact] + public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag() + { + var lessons = new FakeLessons(); + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var date = new DateOnly(2026, 9, 8); + var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" }; + lessons.Add(orphan); + + var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date); + + var found = Assert.Single(candidates); + Assert.Equal(orphan.Id, found.Id); + } + + [Fact] + public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer() + { + var lessons = new FakeLessons(); + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var date = new DateOnly(2026, 9, 8); + var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" }; + lessons.Add(wrongPeriod); + + var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date); + + var found = Assert.Single(candidates); + Assert.Equal(wrongPeriod.Id, found.Id); + } + + [Fact] + public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer() + { + var lessons = new FakeLessons(); + var groupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var date = new DateOnly(2026, 9, 8); + var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" }; + var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" }; + lessons.Add(near); lessons.Add(exact); + + var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date); + + Assert.Equal(2, candidates.Count); + Assert.Equal(exact.Id, candidates[0].Id); + Assert.Equal(near.Id, candidates[1].Id); + } + + [Fact] + public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen() + { + var lessons = new FakeLessons(); + var groupId = Guid.NewGuid(); + var otherGroupId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + var date = new DateOnly(2026, 9, 8); + lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" }); + lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" }); + // Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten + // (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/ + // Auswahldialog, ob sie passt. + lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" }); + + var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date); + + var found = Assert.Single(candidates); + Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic); + } + + [Fact] + public void FindCandidates_LeerOhnePassendeStunde() + { + var lessons = new FakeLessons(); + var groupId = Guid.NewGuid(); + var date = new DateOnly(2026, 9, 8); + + var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date); + + Assert.Empty(candidates); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index f90c505..1c1fdad 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -745,8 +745,10 @@ public partial class LessonDialogViewModel : ObservableObject // gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat // keine echte Id, auf die sich die KI beziehen könnte. public Guid UnitId => _unitId; + public Guid GroupId => _groupId; public Lesson? EditingLesson => _editingLesson; public bool CanAiAssist => _editingLesson is not null; + public bool IsNewLesson => _editingLesson is null; /// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt /// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt @@ -1258,6 +1260,56 @@ public partial class ChangeLessonUnitDialogViewModel : ObservableObject } } +// ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ──── + +/// +/// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import +/// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende +/// haben und deshalb von TimetableViewModel.FindLessonForSlot nicht gefunden werden. +/// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster +/// testbar ist — Aufrufer ist LessonDialog.axaml.cs (Button "Vorhandene Stunde verknüpfen"). +/// +public static class LessonFixItSearch +{ + public static List FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) => + [.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14)) + .Where(l => l.LessonNumber is null || l.Date == date) + .OrderBy(l => l.Date == date ? 0 : 1) + .ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))]; +} + +public sealed class LessonLinkOption(Lesson lesson, string unitTitle) +{ + public Lesson Model { get; } = lesson; + public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic; + public string Detail { get; } = + $"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“"; +} + +public partial class LinkExistingLessonDialogViewModel : ObservableObject +{ + [ObservableProperty] private LessonLinkOption? _selectedOption; + [ObservableProperty] private string _error = ""; + + public ObservableCollection Options { get; } = []; + public Lesson? Result { get; private set; } + + public LinkExistingLessonDialogViewModel(List candidates, IUnitRepository units) + { + foreach (var lesson in candidates) + Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?")); + SelectedOption = Options.FirstOrDefault(); + } + + [RelayCommand] + private void Save() + { + Error = ""; + if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; } + Result = SelectedOption.Model; + } +} + // ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ──────── public partial class GenerateLessonSeriesDialogViewModel : ObservableObject diff --git a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml index cebf0b7..78388c7 100644 --- a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml @@ -185,6 +185,9 @@