feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation

- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 19:45:01 +02:00
co-authored by Claude Sonnet 5
parent f2cb3c98c6
commit a0f44f1b25
19 changed files with 725 additions and 26 deletions
@@ -187,6 +187,60 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.AttachmentError);
}
[Fact]
public void OhneStudentOptions_CanPickStudentIstFalse()
{
var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
Assert.False(vm.CanPickStudent);
}
[Fact]
public void MitStudentOptions_OhneAuswahl_SaveSetztFehlerUndSpeichertNicht()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), [anna])
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.StudentError);
}
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
var groupId = Guid.NewGuid();
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[anna, ben], groupId)
{
Title = "Vorfall", TypeName = "Vorkommnis", SelectedStudent = ben,
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(ben.Id, vm.Result!.StudentId);
Assert.Equal(groupId, vm.Result.GroupId);
}
[Fact]
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var existing = new Documentation { StudentId = ben.Id, Title = "Alt" };
var vm = new DocumentationDialogViewModel(ben.Id, existing, new FakeAttachmentStorage(), [anna, ben]);
Assert.Equal(ben, vm.SelectedStudent);
}
[Fact]
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
{
@@ -26,7 +26,8 @@ public sealed class GroupDetailViewModelTests
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects()));
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -0,0 +1,147 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den bisher als Platzhalter existierenden Gruppen-Tab "Dokumentation" (Nutzer-Feedback):
/// listet Dokumentationseinträge aller Gruppen-Schüler, zeigt Einträge aus anderen Lerngruppen
/// standardmäßig mit an (optisch abgesetzt statt versteckt), mit optionalem Filter darauf.
public sealed class GroupDocumentationTabViewModelTests
{
private static GroupDocumentationTabViewModel BuildVm(
List<Student> students, List<LearningGroup> groups, FakeDocumentation? docs = null)
{
var vm = new GroupDocumentationTabViewModel(docs ?? new FakeDocumentation(),
new FakeStudents(students), new FakeGroups(groups));
return vm;
}
[Fact]
public void Initialize_ZeigtEintraegeAllerGruppenschuelerSortiertNachDatumAbsteigend()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Alt", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Neu", Date = new DateOnly(2025, 9, 10) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
Assert.Equal(2, vm.Entries.Count);
Assert.Equal("Neu", vm.Entries[0].Model.Title);
Assert.Equal("Alt", vm.Entries[1].Model.Title);
}
[Fact]
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Aus anderem Kurs", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.False(entry.IsOwnGroup);
Assert.Equal("Mathematik 9b", entry.OtherGroupLabel);
Assert.True(entry.ContentOpacity < 1.0);
}
[Fact]
public void Initialize_EintragOhneGruppe_GiltAlsEigen()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = null, Title = "Allgemein", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.True(entry.IsOwnGroup);
}
[Fact]
public void OnlyThisGroup_Aktiviert_BlendetEintraegeAusAnderenGruppenAus()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Eigen", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Fremd", Date = new DateOnly(2025, 9, 2) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
vm.OnlyThisGroup = true;
var entry = Assert.Single(vm.Entries);
Assert.Equal("Eigen", entry.Model.Title);
}
[Fact]
public void SelectedStudentFilter_AufEinzelnenSchueler_FiltertListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Anna-Eintrag", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Ben-Eintrag", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
vm.SelectedStudentFilter = vm.StudentFilterOptions.Single(s => s.Id == anna.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Anna-Eintrag", entry.Model.Title);
}
[Fact]
public async Task AddDocumentation_SpeichertErgebnisUndLaedtNeu()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnEditDocumentation = (groupId, options, editing) =>
{
Assert.Equal(group.Id, groupId);
Assert.Contains(options, o => o.Id == anna.Id);
Assert.Null(editing);
return Task.FromResult<Documentation?>(new Documentation
{ StudentId = anna.Id, GroupId = groupId, Title = "Neu", Date = new DateOnly(2025, 9, 1) });
};
await vm.AddDocumentationCommand.ExecuteAsync(null);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Neu", entry.Model.Title);
}
[Fact]
public async Task DeleteDocumentation_NachBestaetigung_EntferntEintragAusListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Weg", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnConfirmDeleteDocumentation = _ => Task.FromResult(true);
await vm.DeleteDocumentationCommand.ExecuteAsync(vm.Entries[0]);
Assert.Empty(vm.Entries);
}
}
@@ -27,6 +27,44 @@ public sealed class QuickInputViewModelTests
Assert.Equal(1, vm.AspectRows[0].Value);
}
[Fact]
public void AnwesenderSchueler_CurrentStudentIsAbsent_IstFalse()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry { Attendance = AttendanceStatus.Present }, aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
Assert.False(vm.CurrentStudentIsAbsent);
Assert.Equal(1.0, vm.CurrentStudentContentOpacity);
}
[Fact]
public void AbwesenderSchueler_CurrentStudentIsAbsent_IstTrueUndGedimmt()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
new(Guid.NewGuid(), "Ben", new ParticipationEntry { Attendance = AttendanceStatus.Unexcused }, aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
vm.NextStudent(); // zu Ben, unentschuldigt abwesend
Assert.True(vm.CurrentStudentIsAbsent);
Assert.Equal("Krank, unentschuldigt", vm.CurrentStudentAttendanceLabel);
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
}
[Fact]
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
{
@@ -15,14 +15,15 @@ public class ReportGradeCalculationTests
private static ReportGradeDialogViewModel BuildViewModel(
FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades,
FakeExams exams, FakeResults results)
FakeExams exams, FakeResults results, FakeSessions? sessions = null, FakeEntries? entries = null)
{
var students = new FakeStudents([Anna, Ben]);
var memberships = new FakeMemberships([]);
var grading = new GradingService();
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
schemes, reportGrades, sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
new AttendanceBalanceService(), grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
"Testgruppe", "2025/26");
}
@@ -68,6 +69,28 @@ public class ReportGradeCalculationTests
Assert.Equal("", ben.CalculatedDisplay);
}
[Fact]
public void AbsenceRatePercent_BerechnetFehlquoteAusSitzungenDieserGruppeImZeitraum()
{
var (_, _, exams, results) = BuildExams();
var session1 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 5) };
var session2 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 12) };
var sessions = new FakeSessions([session1, session2]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = session1.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Present });
entries.Add(new ParticipationEntry { SessionId = session2.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Unexcused });
var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results,
sessions, entries);
var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
Assert.Equal(50.0, anna.AbsenceRatePercent);
Assert.True(anna.HasHighAbsenceRate);
var ben = vm.Rows.Single(r => r.StudentId == Ben.Id);
Assert.Equal(0.0, ben.AbsenceRatePercent);
Assert.False(ben.HasHighAbsenceRate);
}
[Fact]
public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
{