Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste

Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:21:49 +02:00
co-authored by Claude Sonnet 5
parent cd08bbbbc4
commit 229ef79e75
5 changed files with 275 additions and 135 deletions
@@ -14,10 +14,11 @@ public sealed class TimetableViewModelTests
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
PeriodScheduleService? periodSchedule = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
periodSchedule ?? new PeriodScheduleService(tempPath));
}
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
@@ -336,6 +338,80 @@ public sealed class TimetableViewModelTests
Assert.Equal(lesson.Id, openedLesson?.Id);
}
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
[Fact]
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == today.DayOfWeek && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
Assert.Null(viewerLesson);
}
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
[Fact]
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var today = DateOnly.FromDateTime(DateTime.Today);
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
var otherDate = DateInCurrentWeek(otherWeekday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var periodSchedule = BuildPeriodSchedule(1);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
Lesson? teachingModeLesson = null;
Lesson? viewerLesson = null;
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
Assert.Equal(lesson.Id, viewerLesson?.Id);
Assert.Null(teachingModeLesson);
}
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
var service = new PeriodScheduleService(tempPath);
var now = TimeOnly.FromDateTime(DateTime.Now);
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
return service;
}
[Fact]
public void OpenSettings_RuftOnNavigateToSettingsAuf()
{
@@ -1178,44 +1254,42 @@ public sealed class TimetableViewModelTests
}
}
/// Nutzer-Feedback: unklar, wie man aus der "Heute"-Tagesliste zwischen Unterrichtsansicht,
/// Sitzplan, Planung und Planungsviewer wechselt — TodayLessonItem.DestinationOptions ersetzt die
/// bisherigen zwei Buttons durch ein Dropdown mit ausdrücklich benannten Zielen.
public sealed class TodayLessonItemDestinationOptionsTests
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
public sealed class WeekCellItemMenuVisibilityTests
{
[Fact]
public void MitLesson_BietetAlleVierZiele()
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
{
var lesson = new Lesson { Topic = "Redox" };
var item = new TodayLessonItem(Guid.NewGuid(), 3, "Q1 Chemie", "R204",
"#4C8DFF", "Redox", null, lesson: lesson);
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
Assert.True(item.HasDestinationOptions);
Assert.Equal(
[
TimetableLessonDestination.TeachingMode, TimetableLessonDestination.Viewer,
TimetableLessonDestination.SeatingPlan, TimetableLessonDestination.Planning,
],
item.DestinationOptions.Select(o => o.Kind));
Assert.True(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void OhneLesson_BietetNurSitzplanUndPlanung()
public void ForSlot_OhneLesson_HatKeinHasLesson()
{
var item = new TodayLessonItem(Guid.NewGuid(), 3, "Q1 Chemie", "R204",
"#4C8DFF", null, null);
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
Assert.Equal(
[TimetableLessonDestination.SeatingPlan, TimetableLessonDestination.Planning],
item.DestinationOptions.Select(o => o.Kind));
Assert.False(cell.HasLesson);
Assert.True(cell.HasGroupId);
}
[Fact]
public void OhneGroupId_BietetKeineZiele()
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
{
var item = TodayLessonItem.ForSubstitution(3, new SubstitutionEntry { GroupId = null, Description = "Vertretung" });
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
Assert.False(item.HasDestinationOptions);
Assert.Empty(item.DestinationOptions);
Assert.False(cell.HasGroupId);
}
}