Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes

Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:17:18 +02:00
co-authored by Claude Sonnet 5
parent 4d2a89b14b
commit 52a1d45539
16 changed files with 505 additions and 57 deletions
@@ -6,8 +6,9 @@ using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
/// (offene Aufgaben, ...).
public sealed class DashboardViewModelTests
{
private static PeriodScheduleService NewPeriodSchedule()
@@ -41,7 +42,7 @@ public sealed class DashboardViewModelTests
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null)
FakeSessions? sessions = null, FakeEntries? entries = null)
{
lessons ??= new FakeLessons();
lessons.Add(lesson);
@@ -49,7 +50,7 @@ public sealed class DashboardViewModelTests
new FakeGroups([group]), new FakeSubjects([]), lessons,
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), new FakeEntries(),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
@@ -243,6 +244,45 @@ public sealed class DashboardViewModelTests
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
}
[Fact]
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
{
// Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über
// LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich
// als eigener Sitzungstermin auftauchen.
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" };
var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" };
var vm = BuildVm(group, lesson, sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.False(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Single(vm.SelectedDayEvents);
Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession);
}
[Fact]
public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem()
{
// Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan
// gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde.
var today = DateOnly.FromDateTime(DateTime.Today);
var tomorrow = today.AddDays(1);
var group = new LearningGroup { Name = "9c", IsOwnClass = true };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots);
var day = vm.CalendarDays.Single(d => d.Date == tomorrow);
Assert.True(day.IsOwnClassDay);
}
[Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{
@@ -373,4 +413,53 @@ public sealed class DashboardViewModelTests
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung.
for (var i = 0; i < 3; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings);
}
[Fact]
public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe.
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
var item = Assert.Single(vm.AttendanceWarnings);
Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent);
}
}
@@ -254,4 +254,51 @@ public sealed class GroupOverviewViewModelTests
Assert.Equal([6, 4, 3, 8], indices);
}
[Fact]
public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" };
var studentDone = new Student { FirstName = "Ben", LastName = "Muster" };
var students = new FakeStudents([studentMissing, studentDone]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) };
sessions.Save(olderSession);
entries.Add(new ParticipationEntry
{
SessionId = olderSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true,
});
var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) };
sessions.Save(lastSession);
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentMissing.Id,
Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true,
});
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.Completed, HomeworkMissing = false,
});
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.MissingHomeworkStudents);
Assert.Equal(studentMissing.FullName, item.StudentName);
Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel);
}
[Fact]
public void FehlendeHausaufgaben_OhneOffenePostenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasMissingHomework);
}
}
@@ -284,4 +284,50 @@ public sealed class SeatingPlanViewModelTests
Assert.False(vm.CanEdit);
Assert.Empty(sessions.GetByGroup(groupId));
}
[Fact]
public void Initialize_LegtOhneVorhandeneSitzungKeineGeistersitzungAn()
{
// Regressionstest: Initialize() läuft schon beim bloßen Navigieren zur Kursübersicht
// (GroupDetailViewModel.LoadGroup ruft SeatingPlanTab.Initialize für jede Gruppe auf),
// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Vorher legte
// LoadTodaySessions() dabei sofort eine "Sitzplan"-Sitzung für heute an.
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var sessions = new FakeSessions([]);
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
Assert.Empty(sessions.GetByGroup(groupId));
Assert.Null(vm.SelectedSession);
}
[Fact]
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var sessions = new FakeSessions([]);
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.OnAssessStudent = _ => Task.CompletedTask;
Assert.Empty(sessions.GetByGroup(groupId));
await vm.AssessStudent(vm.Seats.Single());
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
Assert.Equal("Sitzplan", session.Comment);
Assert.Equal(session.Id, vm.SelectedSession?.Id);
}
}
@@ -171,7 +171,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
vm.OnEditTask = (_, _) => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
await vm.AddTaskCommand.ExecuteAsync(null);
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
Assert.Equal("Neu", vm.Tasks[0].Title);
}
[Fact]
public async Task AddReminder_OeffnetDialogVorbelegtAlsErinnerungUndSpeichertErgebnis()
{
var tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
bool? passedStartAsReminder = null;
vm.OnEditTask = (_, startAsReminder) =>
{
passedStartAsReminder = startAsReminder;
return Task.FromResult<WorkTask?>(new WorkTask { Title = "Ansage machen", Kind = TaskKind.Reminder });
};
await vm.AddReminderCommand.ExecuteAsync(null);
Assert.True(passedStartAsReminder);
var item = Assert.Single(vm.Tasks);
Assert.True(item.IsReminder);
Assert.Equal("Erinnerung", item.CategoryLabel);
}
[Fact]
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
{
@@ -285,6 +305,38 @@ public sealed class AddEditWorkTaskDialogViewModelTests
Assert.NotNull(vm.Result);
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
}
[Fact]
public void Save_AlsErinnerung_SetztKindUndVerwirftGeschaetzteDauer()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "Ansage an die Klasse machen",
IsReminder = true,
EstimatedMinutesText = "90", // sollte ignoriert werden
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(TaskKind.Reminder, vm.Result!.Kind);
Assert.Null(vm.Result.EstimatedMinutes);
}
[Fact]
public void Konstruktor_StartAsReminder_SetztIsReminderVorbelegt()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [], startAsReminder: true);
Assert.True(vm.IsReminder);
}
[Fact]
public void Konstruktor_BestehendeErinnerung_ErkenntKindWieder()
{
var source = new WorkTask { Title = "Alt", Kind = TaskKind.Reminder };
var vm = new AddEditWorkTaskDialogViewModel(source, []);
Assert.True(vm.IsReminder);
}
}
public sealed class TimeTrackingViewModelTests