feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
und der letzten Stunde der Einheit vorbelegt statt auf "heute"
(behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
für die zweite Periode) und Berücksichtigung von Stundenausfall.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,14 +26,23 @@ public sealed class DashboardViewModelTests
|
||||
return new DashboardSettingsService(tempPath);
|
||||
}
|
||||
|
||||
private static SchoolCalendarSettingsService NewCalendarSettings()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new SchoolCalendarSettingsService(tempPath);
|
||||
}
|
||||
|
||||
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
|
||||
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
|
||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
|
||||
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
|
||||
DashboardSettingsService? dashboardSettings = null)
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
return new DashboardViewModel(
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
@@ -42,7 +51,9 @@ public sealed class DashboardViewModelTests
|
||||
tasks ?? new FakeWorkTasks(), new FakeSessions([]), new FakeEntries(),
|
||||
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings());
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -227,4 +238,119 @@ public sealed class DashboardViewModelTests
|
||||
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
|
||||
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
|
||||
}
|
||||
|
||||
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
|
||||
// Vorgriff fällt und den Test verfälscht.
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
var item = Assert.Single(vm.UnplannedLessons);
|
||||
Assert.Equal(group.Id, item.GroupId);
|
||||
Assert.Equal(1, item.PeriodNumber);
|
||||
Assert.Equal("Heute", item.DateDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
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 = "Redoxreaktionen" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_Feiertag_WirdUebersprungen()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
|
||||
var schoolHolidays = new FakeSchoolHolidays();
|
||||
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Equal(2, vm.UnplannedLessons.Count);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
|
||||
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
|
||||
var substitutions = new FakeSubstitutionEntries();
|
||||
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
|
||||
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Sync;
|
||||
|
||||
@@ -47,6 +48,14 @@ public static class TestSupport
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new EventQueue(Path.Combine(tempPath, "queue.db"));
|
||||
}
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static PeriodScheduleService BuildPeriodScheduleService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new PeriodScheduleService(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
|
||||
@@ -388,4 +388,53 @@ public sealed class LessonDialogViewModelTests
|
||||
|
||||
Assert.False(vm.HasTimeBudgetInfo);
|
||||
}
|
||||
|
||||
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
|
||||
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
|
||||
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
|
||||
{
|
||||
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
|
||||
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
|
||||
Assert.Equal("13.01.2099", vm.DateText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
|
||||
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
|
||||
|
||||
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
|
||||
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
|
||||
|
||||
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
|
||||
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
|
||||
Assert.Equal(weekday, suggested.DayOfWeek);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Workload;
|
||||
@@ -288,14 +289,16 @@ public sealed class AddEditWorkTaskDialogViewModelTests
|
||||
|
||||
public sealed class TimeTrackingViewModelTests
|
||||
{
|
||||
private static TimeTrackingViewModel BuildVm(ITimeEntryRepository entries, IWorkTaskRepository tasks,
|
||||
FakeTimetableSlots? slots = null) =>
|
||||
new(entries, tasks, slots ?? new FakeTimetableSlots(), TestSupport.BuildPeriodScheduleService());
|
||||
|
||||
[Fact]
|
||||
public void StartStopTimer_SpeichertZeiteintragMitMindestensEinerMinute()
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks())
|
||||
{
|
||||
SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction),
|
||||
};
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
vm.SelectedTimerCategory = TaskCategoryDisplay.Label(TaskCategory.Correction);
|
||||
|
||||
vm.StartTimerCommand.Execute(null);
|
||||
Assert.True(vm.IsTimerRunning);
|
||||
@@ -314,7 +317,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
var task = new WorkTask { Title = "T", Category = TaskCategory.Meeting };
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(task);
|
||||
var vm = new TimeTrackingViewModel(new FakeTimeEntries(), tasks);
|
||||
var vm = BuildVm(new FakeTimeEntries(), tasks);
|
||||
|
||||
vm.SelectedTimerTask = task;
|
||||
|
||||
@@ -328,7 +331,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today).AddDays(-30), DurationMinutes = 45 });
|
||||
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
Assert.Single(vm.WeekEntries);
|
||||
Assert.Equal(30, vm.WeekEntries[0].Model.DurationMinutes);
|
||||
@@ -343,7 +346,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
entries.Add(new TimeEntry { Category = "Korrektur", Date = today, DurationMinutes = 20 });
|
||||
entries.Add(new TimeEntry { Category = "Verwaltung", Date = today, DurationMinutes = 10 });
|
||||
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
var korrektur = vm.CategorySummaries.Single(s => s.Category == "Korrektur");
|
||||
Assert.Equal("50 min", korrektur.MinutesDisplay);
|
||||
@@ -356,7 +359,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
entries.Add(new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 30 });
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
|
||||
vm.DeleteEntryCommand.Execute(vm.WeekEntries[0]);
|
||||
|
||||
@@ -367,7 +370,7 @@ public sealed class TimeTrackingViewModelTests
|
||||
public async Task AddEntry_RuftOnAddEntryAufUndSpeichertErgebnis()
|
||||
{
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks());
|
||||
var vm = BuildVm(entries, new FakeWorkTasks());
|
||||
vm.OnAddEntry = () => Task.FromResult<TimeEntry?>(
|
||||
new TimeEntry { Category = "X", Date = DateOnly.FromDateTime(DateTime.Today), DurationMinutes = 15 });
|
||||
|
||||
@@ -375,6 +378,64 @@ public sealed class TimeTrackingViewModelTests
|
||||
|
||||
Assert.Single(vm.WeekEntries);
|
||||
}
|
||||
|
||||
// ── Unterrichtszeit-Vorschlag (Nutzer-Feedback: Zeiterfassung soll bei der Unterrichtszeit
|
||||
// nachfragen statt sie komplett automatisch zu erfassen) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasTeachingTimeSuggestionToday_OhneStundenplanHeute_IstFalse()
|
||||
{
|
||||
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks()); // leere FakeTimetableSlots
|
||||
|
||||
Assert.False(vm.HasTeachingTimeSuggestionToday);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestTeachingTime_SchlaegtBeginnMitPufferVorErsterUndNachLetzterPeriodeVor()
|
||||
{
|
||||
var today = DateTime.Today.DayOfWeek;
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 1 });
|
||||
slots.Add(new TimetableSlot { Weekday = today, PeriodNumber = 2 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([
|
||||
new PeriodTimeEntry { PeriodNumber = 1, Start = new TimeOnly(7, 50), End = new TimeOnly(8, 35) },
|
||||
new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 35), End = new TimeOnly(9, 20) },
|
||||
]);
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||
|
||||
Assert.True(vm.HasTeachingTimeSuggestionToday);
|
||||
|
||||
(TimeOnly Start, TimeOnly End)? suggested = null;
|
||||
vm.OnSuggestTeachingTime = (start, end) =>
|
||||
{
|
||||
suggested = (start, end);
|
||||
return Task.FromResult<TimeEntry?>(new TimeEntry
|
||||
{
|
||||
Category = "Unterricht", Date = DateOnly.FromDateTime(DateTime.Today),
|
||||
StartTime = start, EndTime = end, DurationMinutes = (int)(end - start).TotalMinutes,
|
||||
});
|
||||
};
|
||||
|
||||
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(new TimeOnly(7, 35), suggested!.Value.Start); // 7:50 - 15 Min Puffer
|
||||
Assert.Equal(new TimeOnly(9, 30), suggested.Value.End); // 9:20 + 10 Min Puffer
|
||||
Assert.Single(entries.GetByDate(DateOnly.FromDateTime(DateTime.Today)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestTeachingTime_OhneStundenplanHeute_TutNichts()
|
||||
{
|
||||
var vm = BuildVm(new FakeTimeEntries(), new FakeWorkTasks());
|
||||
var called = false;
|
||||
vm.OnSuggestTeachingTime = (_, _) => { called = true; return Task.FromResult<TimeEntry?>(null); };
|
||||
|
||||
await vm.SuggestTeachingTimeCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddTimeEntryDialogViewModelTests
|
||||
|
||||
Reference in New Issue
Block a user