diff --git a/LehrerApp.Core/Services/SchoolYearService.cs b/LehrerApp.Core/Services/SchoolYearService.cs
index ecbb295..64826ac 100644
--- a/LehrerApp.Core/Services/SchoolYearService.cs
+++ b/LehrerApp.Core/Services/SchoolYearService.cs
@@ -2,21 +2,26 @@ namespace LehrerApp.Core.Services;
public class SchoolYearService
{
- public string CurrentSchoolYear()
- {
- var now = DateTime.Today;
- return FormatSchoolYear(now.Month >= 8 ? now.Year : now.Year - 1);
- }
+ /// Nur für Tests — Standard ist das heutige Datum.
+ public string CurrentSchoolYear(DateOnly? today = null) =>
+ FormatSchoolYear(StartYearFor(today ?? DateOnly.FromDateTime(DateTime.Today)));
+
public string FormatSchoolYear(int startYear) =>
$"{startYear}/{(startYear + 1) % 100:D2}";
+
public DateOnly SchoolYearStart(string sy) =>
new(int.Parse(sy.Split('/')[0]), 8, 1);
+
public DateOnly SchoolYearEnd(string sy) =>
new(int.Parse(sy.Split('/')[0]) + 1, 7, 31);
- public List RecentSchoolYears(int count = 5)
+
+ /// Nur für Tests — Standard ist das heutige Datum.
+ public List RecentSchoolYears(int count = 5, DateOnly? today = null)
{
- var now = DateTime.Today;
- var cur = now.Month >= 8 ? now.Year : now.Year - 1;
+ var cur = StartYearFor(today ?? DateOnly.FromDateTime(DateTime.Today));
return Enumerable.Range(0, count).Select(i => FormatSchoolYear(cur - i)).ToList();
}
+
+ // Schuljahreswechsel liegt am 1. August: ab diesem Tag zählt das neue Schuljahr.
+ private static int StartYearFor(DateOnly date) => date.Month >= 8 ? date.Year : date.Year - 1;
}
diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs
new file mode 100644
index 0000000..ebbf42a
--- /dev/null
+++ b/LehrerApp.Data.Tests/RepositoryTests.cs
@@ -0,0 +1,334 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Data.Repositories;
+using Xunit;
+
+namespace LehrerApp.Data.Tests;
+
+public sealed class RepositoryTests
+{
+ private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
+
+ // ── StudentRepository ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void StudentRepository_GetByGroup_FindetNurZugeordneteSchueler()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new StudentRepository(db);
+ var groupId = Guid.NewGuid();
+ var inGroup = new Student { FirstName = "Anna", LastName = "Beispiel" };
+ var notInGroup = new Student { FirstName = "Ben", LastName = "Muster" };
+ repo.Save(inGroup);
+ repo.Save(notInGroup);
+ db.Memberships.Insert(new GroupMembership { StudentId = inGroup.Id, GroupId = groupId });
+
+ var result = repo.GetByGroup(groupId);
+
+ Assert.Single(result);
+ Assert.Equal(inGroup.Id, result[0].Id);
+ }
+
+ [Fact]
+ public void StudentRepository_GetAll_OhneInactiveFiltertDeaktivierteSchuelerAus()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new StudentRepository(db);
+ repo.Save(new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true });
+ repo.Save(new Student { FirstName = "Ben", LastName = "Inaktiv", IsActive = false });
+
+ Assert.Single(repo.GetAll(includeInactive: false));
+ Assert.Equal(2, repo.GetAll(includeInactive: true).Count);
+ }
+
+ // ── GroupRepository ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void GroupRepository_Save_LehntUnbekanntesFachAb()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new GroupRepository(db);
+ var group = new LearningGroup { Name = "Mathe G", SchoolYear = "2025/26", SubjectId = Guid.NewGuid() };
+
+ Assert.Throws(() => repo.Save(group));
+ }
+
+ [Fact]
+ public void GroupRepository_Save_AkzeptiertVorhandenesFach()
+ {
+ using var db = NewInMemoryContext();
+ var subjectRepo = new SubjectRepository(db);
+ var groupRepo = new GroupRepository(db);
+ var subject = new Subject { Name = "Mathematik" };
+ subjectRepo.Save(subject);
+
+ groupRepo.Save(new LearningGroup { Name = "Mathe G", SchoolYear = "2025/26", SubjectId = subject.Id });
+
+ Assert.NotNull(groupRepo.GetAll(includeInactive: true).FirstOrDefault(g => g.Name == "Mathe G"));
+ }
+
+ [Fact]
+ public void GroupRepository_Delete_LoeschtAlleAbhaengigenDatensaetzeKaskadierend()
+ {
+ using var db = NewInMemoryContext();
+ var groupRepo = new GroupRepository(db);
+ var groupId = Guid.NewGuid();
+ groupRepo.Save(new LearningGroup { Id = groupId, Name = "Mathe G", SchoolYear = "2025/26" });
+
+ var studentId = Guid.NewGuid();
+ var membershipId = Guid.NewGuid();
+ db.Memberships.Insert(new GroupMembership { Id = membershipId, StudentId = studentId, GroupId = groupId });
+
+ var examId = Guid.NewGuid();
+ var examResultId = Guid.NewGuid();
+ db.Exams.Insert(new Exam { Id = examId, GroupId = groupId });
+ db.ExamResults.Insert(new ExamResult { Id = examResultId, ExamId = examId, StudentId = studentId });
+
+ var gradeId = Guid.NewGuid();
+ db.Grades.Insert(new Grade { Id = gradeId, GroupId = groupId, StudentId = studentId });
+
+ var unitId = Guid.NewGuid();
+ var lessonInUnitId = Guid.NewGuid();
+ db.Units.Insert(new Unit { Id = unitId, GroupId = groupId });
+ db.Lessons.Insert(new Lesson { Id = lessonInUnitId, UnitId = unitId, GroupId = groupId });
+
+ var looseLessonId = Guid.NewGuid();
+ db.Lessons.Insert(new Lesson { Id = looseLessonId, UnitId = Guid.NewGuid(), GroupId = groupId });
+
+ var sessionId = Guid.NewGuid();
+ var entryId = Guid.NewGuid();
+ db.ParticipationSessions.Insert(new ParticipationSession { Id = sessionId, GroupId = groupId });
+ db.ParticipationEntries.Insert(new ParticipationEntry { Id = entryId, SessionId = sessionId, StudentId = studentId });
+
+ var aspectId = Guid.NewGuid();
+ db.ParticipationAspects.Insert(new ParticipationAspect { Id = aspectId, GroupId = groupId, Key = "quality" });
+
+ groupRepo.Delete(groupId);
+
+ Assert.Null(db.Groups.FindById(groupId));
+ Assert.Null(db.Memberships.FindById(membershipId));
+ Assert.Null(db.Exams.FindById(examId));
+ Assert.Null(db.ExamResults.FindById(examResultId));
+ Assert.Null(db.Grades.FindById(gradeId));
+ Assert.Null(db.Units.FindById(unitId));
+ Assert.Null(db.Lessons.FindById(lessonInUnitId));
+ Assert.Null(db.Lessons.FindById(looseLessonId));
+ Assert.Null(db.ParticipationSessions.FindById(sessionId));
+ Assert.Null(db.ParticipationEntries.FindById(entryId));
+ Assert.Null(db.ParticipationAspects.FindById(aspectId));
+ }
+
+ [Fact]
+ public void GroupRepository_Delete_LaesstDatenAndererGruppenUnberuehrt()
+ {
+ using var db = NewInMemoryContext();
+ var groupRepo = new GroupRepository(db);
+ var deletedGroupId = Guid.NewGuid();
+ var otherGroupId = Guid.NewGuid();
+ groupRepo.Save(new LearningGroup { Id = deletedGroupId, Name = "Löschen", SchoolYear = "2025/26" });
+ groupRepo.Save(new LearningGroup { Id = otherGroupId, Name = "Bleibt", SchoolYear = "2025/26" });
+
+ var otherExamId = Guid.NewGuid();
+ db.Exams.Insert(new Exam { Id = otherExamId, GroupId = otherGroupId });
+
+ groupRepo.Delete(deletedGroupId);
+
+ Assert.Null(db.Groups.FindById(deletedGroupId));
+ Assert.NotNull(db.Groups.FindById(otherGroupId));
+ Assert.NotNull(db.Exams.FindById(otherExamId));
+ }
+
+ // ── GroupMembershipRepository ────────────────────────────────────────────
+
+ [Fact]
+ public void GroupMembershipRepository_Save_LehntZweiteZuordnungFuerGleichesPaarAb()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new GroupMembershipRepository(db);
+ var studentId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ repo.Save(new GroupMembership { StudentId = studentId, GroupId = groupId });
+
+ var duplicate = new GroupMembership { StudentId = studentId, GroupId = groupId };
+ Assert.Throws(() => repo.Save(duplicate));
+ }
+
+ [Fact]
+ public void GroupMembershipRepository_Save_ErlaubtAktualisierenDerselbenZuordnung()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new GroupMembershipRepository(db);
+ var membership = new GroupMembership { StudentId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Period = MembershipPeriod.FullYear };
+ repo.Save(membership);
+
+ membership.Period = MembershipPeriod.H1Only;
+ repo.Save(membership); // gleiche Id -> darf keine Ausnahme werfen
+
+ var reloaded = repo.GetByStudentAndGroup(membership.StudentId, membership.GroupId);
+ Assert.Equal(MembershipPeriod.H1Only, reloaded?.Period);
+ }
+
+ // ── ExamRepository ────────────────────────────────────────────────────────
+
+ [Fact]
+ public void ExamRepository_Delete_LoeschtZugehoerigeErgebnisseMit()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new ExamRepository(db);
+ var exam = new Exam { GroupId = Guid.NewGuid() };
+ repo.Save(exam);
+ var resultId = Guid.NewGuid();
+ db.ExamResults.Insert(new ExamResult { Id = resultId, ExamId = exam.Id, StudentId = Guid.NewGuid() });
+
+ repo.Delete(exam.Id);
+
+ Assert.Null(repo.GetById(exam.Id));
+ Assert.Null(db.ExamResults.FindById(resultId));
+ }
+
+ [Fact]
+ public void ExamRepository_GetByGroup_SortiertAbsteigendNachDatum()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new ExamRepository(db);
+ var groupId = Guid.NewGuid();
+ repo.Save(new Exam { GroupId = groupId, Title = "Früh", Date = new DateOnly(2025, 9, 1) });
+ repo.Save(new Exam { GroupId = groupId, Title = "Spät", Date = new DateOnly(2025, 11, 1) });
+
+ var result = repo.GetByGroup(groupId);
+
+ Assert.Equal("Spät", result[0].Title);
+ Assert.Equal("Früh", result[1].Title);
+ }
+
+ // ── SubjectRepository ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void SubjectRepository_Save_TrimmtNamenUndKuerzel()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new SubjectRepository(db);
+ var subject = new Subject { Name = " Chemie ", ShortName = " Ch " };
+
+ repo.Save(subject);
+
+ var reloaded = repo.GetById(subject.Id);
+ Assert.Equal("Chemie", reloaded?.Name);
+ Assert.Equal("Ch", reloaded?.ShortName);
+ }
+
+ [Fact]
+ public void SubjectRepository_Save_LehntLeerenNamenAb()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new SubjectRepository(db);
+
+ Assert.Throws(() => repo.Save(new Subject { Name = " " }));
+ }
+
+ [Fact]
+ public void SubjectRepository_Save_LehntDuplikatUnabhaengigVonGrossKleinschreibungAb()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new SubjectRepository(db);
+ repo.Save(new Subject { Name = "Chemie" });
+
+ Assert.Throws(() => repo.Save(new Subject { Name = "chemie" }));
+ }
+
+ [Fact]
+ public void SubjectRepository_Delete_WirdBlockiertWennGruppeDasFachVerwendet()
+ {
+ using var db = NewInMemoryContext();
+ var subjectRepo = new SubjectRepository(db);
+ var subject = new Subject { Name = "Chemie" };
+ subjectRepo.Save(subject);
+ db.Groups.Insert(new LearningGroup { Name = "Chemie G", SchoolYear = "2025/26", SubjectId = subject.Id });
+
+ Assert.Throws(() => subjectRepo.Delete(subject.Id));
+ }
+
+ [Fact]
+ public void SubjectRepository_Delete_FunktioniertWennUnbenutzt()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new SubjectRepository(db);
+ var subject = new Subject { Name = "Chemie" };
+ repo.Save(subject);
+
+ repo.Delete(subject.Id);
+
+ Assert.Null(repo.GetById(subject.Id));
+ }
+
+ // ── GradingSchemeRepository ───────────────────────────────────────────────
+
+ [Fact]
+ public void GradingSchemeRepository_UnterscheidetGruppenspezifischesSchemaVonVoreinstellung()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new GradingSchemeRepository(db);
+ var groupId = Guid.NewGuid();
+ repo.Save(new GradingScheme { GroupId = groupId, ExamsPercent = 60, ParticipationPercent = 30, OtherPercent = 10 });
+ repo.Save(new GradingScheme { GroupType = GroupType.Class, ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
+
+ var forGroup = repo.GetByGroup(groupId);
+ var defaultForClass = repo.GetDefaultForType(GroupType.Class);
+ var defaultForCourse = repo.GetDefaultForType(GroupType.Course);
+
+ Assert.Equal(60, forGroup?.ExamsPercent);
+ Assert.Equal(50, defaultForClass?.ExamsPercent);
+ Assert.Null(defaultForCourse);
+ }
+
+ // ── ReportGradeRepository ─────────────────────────────────────────────────
+
+ [Fact]
+ public void ReportGradeRepository_GetByStudentGroupPeriod_UnterscheidetZeitraeume()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new ReportGradeRepository(db);
+ var studentId = Guid.NewGuid();
+ var groupId = Guid.NewGuid();
+ repo.Save(new ReportGrade { StudentId = studentId, GroupId = groupId, Period = "1. Halbjahr", CalculatedValue = "2" });
+ repo.Save(new ReportGrade { StudentId = studentId, GroupId = groupId, Period = "2. Halbjahr", CalculatedValue = "3" });
+
+ var h1 = repo.GetByStudentGroupPeriod(studentId, groupId, "1. Halbjahr");
+ var h2 = repo.GetByStudentGroupPeriod(studentId, groupId, "2. Halbjahr");
+ var unbekannt = repo.GetByStudentGroupPeriod(studentId, groupId, "Gesamtes Schuljahr");
+
+ Assert.Equal("2", h1?.CalculatedValue);
+ Assert.Equal("3", h2?.CalculatedValue);
+ Assert.Null(unbekannt);
+ }
+
+ // ── ParticipationSessionRepository ───────────────────────────────────────
+
+ [Fact]
+ public void ParticipationSessionRepository_Delete_LoeschtZugehoerigeEintraegeMit()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new ParticipationSessionRepository(db);
+ var session = new ParticipationSession { GroupId = Guid.NewGuid() };
+ repo.Save(session);
+ var entryId = Guid.NewGuid();
+ db.ParticipationEntries.Insert(new ParticipationEntry { Id = entryId, SessionId = session.Id, StudentId = Guid.NewGuid() });
+
+ repo.Delete(session.Id);
+
+ Assert.Null(repo.GetById(session.Id));
+ Assert.Null(db.ParticipationEntries.FindById(entryId));
+ }
+
+ [Fact]
+ public void ParticipationEntry_EindeutigerIndex_LehntZweitenEintragFuerGleicheSitzungUndSchuelerAb()
+ {
+ using var db = NewInMemoryContext();
+ var repo = new ParticipationRepository(db);
+ var sessionId = Guid.NewGuid();
+ var studentId = Guid.NewGuid();
+ repo.Save(new ParticipationEntry { SessionId = sessionId, StudentId = studentId });
+
+ Assert.Throws(() =>
+ db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = sessionId, StudentId = studentId }));
+ }
+}
diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs
index 0b6856f..324cfb9 100644
--- a/LehrerApp.Data/LiteDbContext.cs
+++ b/LehrerApp.Data/LiteDbContext.cs
@@ -20,6 +20,14 @@ public class LiteDbContext : IDisposable
EnsureIndexes();
}
+ /// Für Tests: In-Memory-Datenbank ohne Datei auf der Festplatte.
+ public LiteDbContext(Stream stream)
+ {
+ _db = new LiteDatabase(stream);
+ MigrateExistingData();
+ EnsureIndexes();
+ }
+
public ILiteCollection Students => _db.GetCollection("students");
public ILiteCollection Groups => _db.GetCollection("groups");
public ILiteCollection Memberships => _db.GetCollection("group_memberships");
diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs
new file mode 100644
index 0000000..d6883ee
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/Fakes.cs
@@ -0,0 +1,127 @@
+using LehrerApp.Core.Interfaces;
+using LehrerApp.Core.Models;
+
+namespace LehrerApp.Desktop.Tests;
+
+// Einfache In-Memory-Fakes der Repository-Schnittstellen, damit ViewModel-Tests ohne echte
+// LiteDB-Anbindung laufen. Bewusst schlank gehalten: nur was die getesteten ViewModels brauchen.
+
+public class FakeStudents(List all) : IStudentRepository
+{
+ public Student? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
+ public List GetAll(bool includeInactive = false) => all;
+ public List GetByGroup(Guid groupId) => all;
+ public void Save(Student student) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeMemberships(List all) : IGroupMembershipRepository
+{
+ public List GetByStudent(Guid studentId) => all.Where(m => m.StudentId == studentId).ToList();
+ public List GetByGroup(Guid groupId) => all.Where(m => m.GroupId == groupId).ToList();
+ public GroupMembership? GetByStudentAndGroup(Guid studentId, Guid groupId) =>
+ all.FirstOrDefault(m => m.StudentId == studentId && m.GroupId == groupId);
+ public void Save(GroupMembership membership) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeSessions(List all) : IParticipationSessionRepository
+{
+ public List GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
+ public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
+ public void Save(ParticipationSession session) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeEntries : IParticipationRepository
+{
+ private readonly List _all = [];
+ public void Add(ParticipationEntry e) => _all.Add(e);
+ public List GetBySession(Guid sessionId) => _all.Where(e => e.SessionId == sessionId).ToList();
+ public List GetByStudent(Guid studentId) => _all.Where(e => e.StudentId == studentId).ToList();
+ public ParticipationEntry? GetBySessionAndStudent(Guid sessionId, Guid studentId) =>
+ _all.FirstOrDefault(e => e.SessionId == sessionId && e.StudentId == studentId);
+ public void Save(ParticipationEntry entry)
+ {
+ _all.RemoveAll(e => e.Id == entry.Id);
+ _all.Add(entry);
+ }
+ public void SaveMany(List entries) { foreach (var e in entries) Save(e); }
+ public void DeleteBySession(Guid sessionId) => _all.RemoveAll(e => e.SessionId == sessionId);
+}
+
+public class FakeAspects : IParticipationAspectRepository
+{
+ public List GetDefaults() => [];
+ public List GetByGroup(Guid groupId) => [];
+ public void Save(ParticipationAspect aspect) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeGrades : IGradeRepository
+{
+ private readonly List _all = [];
+ public void Add(Grade g) => _all.Add(g);
+ public List GetByStudentAndGroup(Guid studentId, Guid groupId) =>
+ _all.Where(g => g.StudentId == studentId && g.GroupId == groupId).ToList();
+ public List GetByGroup(Guid groupId) => _all.Where(g => g.GroupId == groupId).ToList();
+ public void Save(Grade grade)
+ {
+ _all.RemoveAll(g => g.Id == grade.Id);
+ _all.Add(grade);
+ }
+ public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
+}
+
+public class FakeExams(List all) : IExamRepository
+{
+ public Exam? GetById(Guid id) => all.FirstOrDefault(e => e.Id == id);
+ public List GetByGroup(Guid groupId) => all.Where(e => e.GroupId == groupId).ToList();
+ public void Save(Exam exam) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeResults : IExamResultRepository
+{
+ private readonly List _all = [];
+ // Upsert-Semantik nach ExamId+StudentId, wie der reale unique Index es erzwingt.
+ public void Add(ExamResult r)
+ {
+ _all.RemoveAll(x => x.ExamId == r.ExamId && x.StudentId == r.StudentId);
+ _all.Add(r);
+ }
+ public List GetByExam(Guid examId) => _all.Where(r => r.ExamId == examId).ToList();
+ public List GetByStudent(Guid studentId) => _all.Where(r => r.StudentId == studentId).ToList();
+ public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
+ _all.FirstOrDefault(r => r.ExamId == examId && r.StudentId == studentId);
+ public void Save(ExamResult result) { }
+ public void SaveMany(List results) { }
+}
+
+public class FakeSchemes : IGradingSchemeRepository
+{
+ private readonly Dictionary _byGroup = [];
+ private readonly Dictionary _byType = [];
+ public void SetForGroup(Guid groupId, GradingScheme s) => _byGroup[groupId] = s;
+ public void SetDefault(GroupType type, GradingScheme s) => _byType[type] = s;
+ public GradingScheme? GetByGroup(Guid groupId) => _byGroup.GetValueOrDefault(groupId);
+ public GradingScheme? GetDefaultForType(GroupType type) => _byType.GetValueOrDefault(type);
+ public void Save(GradingScheme scheme) { }
+ public void Delete(Guid id) { }
+}
+
+public class FakeReportGrades : IReportGradeRepository
+{
+ private readonly List _all = [];
+ public int SavedCount { get; private set; }
+ public List GetByGroup(Guid groupId) => _all.Where(r => r.GroupId == groupId).ToList();
+ public ReportGrade? GetByStudentGroupPeriod(Guid studentId, Guid groupId, string period) =>
+ _all.FirstOrDefault(r => r.StudentId == studentId && r.GroupId == groupId && r.Period == period);
+ public void Save(ReportGrade grade)
+ {
+ _all.RemoveAll(r => r.Id == grade.Id);
+ _all.Add(grade);
+ SavedCount++;
+ }
+ public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
+}
diff --git a/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj b/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj
new file mode 100644
index 0000000..5ed7169
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs b/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs
new file mode 100644
index 0000000..35d43aa
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs
@@ -0,0 +1,160 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+/// Tests für die Mitarbeits-Aggregation (3.2): ParticipationGradeDialogViewModel
+/// verrechnet die Sitzungsbewertungen eines Zeitraums zu einer Mitarbeitsnote je Schüler.
+public class ParticipationGradeAggregationTests
+{
+ private static (ParticipationGradeDialogViewModel Vm, Guid AnnaId, Guid BenId, Guid CoraId, Guid GroupId, FakeGrades Grades)
+ BuildScenario()
+ {
+ var groupId = Guid.NewGuid();
+ var anna = new Student { Id = Guid.NewGuid(), FirstName = "Anna", LastName = "Beispiel" };
+ var ben = new Student { Id = Guid.NewGuid(), FirstName = "Ben", LastName = "Muster" };
+ var cora = new Student { Id = Guid.NewGuid(), FirstName = "Cora", LastName = "Ohne" }; // nie bewertet
+
+ var students = new FakeStudents([anna, ben, cora]);
+ var memberships = new FakeMemberships([]);
+ var aspects = new FakeAspects();
+
+ var sess1 = new ParticipationSession { GroupId = groupId, Date = new DateOnly(2025, 9, 1) };
+ var sess2 = new ParticipationSession { GroupId = groupId, Date = new DateOnly(2025, 9, 15) };
+ var sess3 = new ParticipationSession { GroupId = groupId, Date = new DateOnly(2025, 9, 29) };
+ var sessions = new FakeSessions([sess1, sess2, sess3]);
+
+ var entries = new FakeEntries();
+ // Anna: −1, 0, +2 → steigender Trend, beide Standardaspekte gleich bewertet.
+ entries.Add(new ParticipationEntry
+ {
+ SessionId = sess1.Id, StudentId = anna.Id,
+ Ratings = [new() { Key = "quality", Value = -1 }, new() { Key = "quantity", Value = -1 }],
+ });
+ entries.Add(new ParticipationEntry
+ {
+ SessionId = sess2.Id, StudentId = anna.Id,
+ Ratings = [new() { Key = "quality", Value = 0 }, new() { Key = "quantity", Value = 0 }],
+ });
+ entries.Add(new ParticipationEntry
+ {
+ SessionId = sess3.Id, StudentId = anna.Id,
+ Ratings = [new() { Key = "quality", Value = 2 }, new() { Key = "quantity", Value = 2 }],
+ });
+ // Ben: nur einmal bewertet.
+ entries.Add(new ParticipationEntry
+ {
+ SessionId = sess1.Id, StudentId = ben.Id,
+ Ratings = [new() { Key = "quality", Value = 0 }],
+ });
+ // Cora: nie bewertet.
+
+ var grades = new FakeGrades();
+ var grading = new GradingService();
+
+ var vm = new ParticipationGradeDialogViewModel(sessions, entries, aspects, students, memberships,
+ grades, grading, groupId, "2025/26", GradingSystem.Grades1To6);
+
+ return (vm, anna.Id, ben.Id, cora.Id, groupId, grades);
+ }
+
+ [Fact]
+ public void Recompute_MittelBerechnungGewichtetAlleSitzungenGleich()
+ {
+ var (vm, annaId, _, _, _, _) = BuildScenario();
+
+ var anna = vm.Rows.Single(r => r.StudentId == annaId);
+
+ Assert.Equal(3, anna.SessionCount);
+ Assert.Equal((-1.0 + 0.0 + 2.0) / 3.0, double.Parse(anna.AverageDisplay.Replace(',', '.'),
+ System.Globalization.CultureInfo.InvariantCulture), precision: 2);
+ }
+
+ [Fact]
+ public void Recompute_NichtBewerteterSchueler_HatKeineNoteStattSchlechterNote()
+ {
+ var (vm, _, _, coraId, _, _) = BuildScenario();
+
+ var cora = vm.Rows.Single(r => r.StudentId == coraId);
+
+ Assert.Equal(0, cora.SessionCount);
+ Assert.Null(cora.Grade);
+ Assert.Equal("nicht bewertet", cora.GradeDisplay);
+ }
+
+ [Fact]
+ public void Recompute_EinzelneBewertung_ErgibtTrotzdemEineNote()
+ {
+ var (vm, _, benId, _, _, _) = BuildScenario();
+
+ var ben = vm.Rows.Single(r => r.StudentId == benId);
+
+ Assert.Equal(1, ben.SessionCount);
+ Assert.NotNull(ben.Grade);
+ }
+
+ [Fact]
+ public void Recompute_SteigendeBewertungen_ZeigenAufwaertstrend()
+ {
+ var (vm, annaId, _, _, _, _) = BuildScenario();
+
+ var anna = vm.Rows.Single(r => r.StudentId == annaId);
+
+ Assert.Equal("↑", anna.TrendSymbol);
+ }
+
+ [Fact]
+ public void AspektGewicht_AufNullGesetzt_NimmtAspektAusDerBerechnungHeraus()
+ {
+ var (vm, annaId, _, _, _, _) = BuildScenario();
+ var quantity = vm.AspectWeights.First(a => a.Key == "quantity");
+
+ quantity.Weight = 0;
+
+ // Nur noch "quality" zählt: (-1 + 0 + 2) / 3 = 0.33 — identisch zum Gesamtmittel,
+ // da beide Aspekte bei Anna durchgehend gleich bewertet wurden.
+ var anna = vm.Rows.Single(r => r.StudentId == annaId);
+ var average = double.Parse(anna.AverageDisplay.Replace(',', '.'), System.Globalization.CultureInfo.InvariantCulture);
+ Assert.Equal(1.0 / 3.0, average, precision: 2);
+ }
+
+ [Fact]
+ public void SelectedPeriod_H1_SchliesstSitzungenAusserhalbAus()
+ {
+ var (vm, _, _, _, _, _) = BuildScenario();
+
+ vm.SelectedPeriod = vm.PeriodOptions.First(p => p.Period == ParticipationPeriod.H2);
+
+ // Alle Testsitzungen liegen im September (H1) -> im H2-Zeitraum bleibt niemand bewertet.
+ Assert.All(vm.Rows, r => Assert.Equal(0, r.SessionCount));
+ }
+
+ [Fact]
+ public void Apply_SpeichertEineNoteJeBewertetemSchueler()
+ {
+ var (vm, annaId, benId, coraId, groupId, grades) = BuildScenario();
+
+ vm.ApplyCommand.Execute(null);
+
+ Assert.NotNull(grades.GetByStudentAndGroup(annaId, groupId)
+ .SingleOrDefault(g => g.Category == GradeCategory.Participation));
+ Assert.NotNull(grades.GetByStudentAndGroup(benId, groupId)
+ .SingleOrDefault(g => g.Category == GradeCategory.Participation));
+ Assert.Empty(grades.GetByStudentAndGroup(coraId, groupId));
+ }
+
+ [Fact]
+ public void Apply_ZweitesMal_AktualisiertBestehendeNoteStattZuDuplizieren()
+ {
+ var (vm, annaId, _, _, groupId, grades) = BuildScenario();
+
+ vm.ApplyCommand.Execute(null);
+ vm.ApplyCommand.Execute(null);
+
+ var annaGrades = grades.GetByStudentAndGroup(annaId, groupId)
+ .Where(g => g.Category == GradeCategory.Participation).ToList();
+ Assert.Single(annaGrades);
+ }
+}
diff --git a/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
new file mode 100644
index 0000000..3dfb97d
--- /dev/null
+++ b/LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs
@@ -0,0 +1,197 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using LehrerApp.Desktop.ViewModels.Groups;
+using Xunit;
+
+namespace LehrerApp.Desktop.Tests;
+
+/// Tests für die Zeugnisnotenberechnung (2.4): ReportGradeDialogViewModel kombiniert
+/// Klausuren, Mitarbeit und sonstige Noten gemäß Gewichtungsschema zu einer Zeugnisnote.
+public class ReportGradeCalculationTests
+{
+ private static Guid GroupId { get; } = Guid.NewGuid();
+ private static Student Anna { get; } = new() { Id = Guid.NewGuid(), FirstName = "Anna", LastName = "Beispiel" };
+ private static Student Ben { get; } = new() { Id = Guid.NewGuid(), FirstName = "Ben", LastName = "Muster" };
+
+ private static ReportGradeDialogViewModel BuildViewModel(
+ FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades,
+ FakeExams exams, FakeResults results)
+ {
+ 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, "Testgruppe");
+ }
+
+ private static (Exam Exam1, Exam Exam2, FakeExams Exams, FakeResults Results) BuildExams()
+ {
+ var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 10), Title = "K1" };
+ var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 24), Title = "K2" };
+ var exams = new FakeExams([exam1, exam2]);
+ var results = new FakeResults();
+ results.Add(new ExamResult { ExamId = exam1.Id, StudentId = Anna.Id, Grade = "2" });
+ results.Add(new ExamResult { ExamId = exam2.Id, StudentId = Anna.Id, Grade = "4" });
+ return (exam1, exam2, exams, results);
+ }
+
+ [Fact]
+ public void CalculatedGrade_KombiniertKlausurenMitarbeitUndSonstigeGemaessSchema()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var grades = new FakeGrades();
+ grades.Add(new Grade { StudentId = Anna.Id, GroupId = GroupId, Category = GradeCategory.Participation,
+ Date = new DateOnly(2025, 9, 5), Value = "3", Weight = 1.0 });
+ grades.Add(new Grade { StudentId = Anna.Id, GroupId = GroupId, Category = GradeCategory.Other,
+ Date = new DateOnly(2025, 9, 5), Value = "1", Weight = 1.0 });
+ var schemes = new FakeSchemes();
+ schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 });
+
+ var vm = BuildViewModel(grades, schemes, new FakeReportGrades(), exams, results);
+
+ // Klausuren Ø3 (Note 2 und 4), Mitarbeit 3, Sonstige 1 -> (3*50+3*40+1*10)/100 = 2.8 -> kaufmännisch 3
+ var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
+ Assert.Equal("3", anna.CalculatedValue);
+ }
+
+ [Fact]
+ public void CalculatedGrade_SchuelerOhneJedeNote_ZeigtKeinenWert()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var schemes = new FakeSchemes();
+ var vm = BuildViewModel(new FakeGrades(), schemes, new FakeReportGrades(), exams, results);
+
+ var ben = vm.Rows.Single(r => r.StudentId == Ben.Id);
+ Assert.Null(ben.CalculatedValue);
+ Assert.Equal("–", ben.CalculatedDisplay);
+ }
+
+ [Fact]
+ public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var grades = new FakeGrades();
+ var schemes = new FakeSchemes();
+ schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 100, ParticipationPercent = 0, OtherPercent = 0 });
+ schemes.SetDefault(GroupType.Class, new GradingScheme { ExamsPercent = 0, ParticipationPercent = 0, OtherPercent = 100 });
+
+ var vm = BuildViewModel(grades, schemes, new FakeReportGrades(), exams, results);
+
+ // Gruppenspezifisches Schema gewichtet nur Klausuren (Ø3 aus Note 2 und 4) -> exakt "3"
+ Assert.Contains("Klausuren 100", vm.SchemeSummary);
+ var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
+ Assert.Equal("3", anna.CalculatedValue);
+ }
+
+ [Fact]
+ public void ResolveScheme_OhneGruppenschema_VerwendetVoreinstellungDesGruppentyps()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var schemes = new FakeSchemes();
+ schemes.SetDefault(GroupType.Class, new GradingScheme { ExamsPercent = 70, ParticipationPercent = 20, OtherPercent = 10 });
+
+ var vm = BuildViewModel(new FakeGrades(), schemes, new FakeReportGrades(), exams, results);
+
+ Assert.Contains("Klausuren 70", vm.SchemeSummary);
+ }
+
+ [Fact]
+ public void ResolveScheme_OhneJedeVoreinstellung_VerwendetHartcodiertenFallback()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results);
+
+ Assert.Contains("Klausuren 50", vm.SchemeSummary);
+ Assert.Contains("Mitarbeit 40", vm.SchemeSummary);
+ Assert.Contains("Sonstige 10", vm.SchemeSummary);
+ }
+
+ [Fact]
+ public void Save_UebersteuernOhneBegruendung_LiefertValidierungsfehlerUndSpeichertNicht()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var reportGrades = new FakeReportGrades();
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, exams, results);
+ var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
+
+ anna.OverrideValue = "1";
+ anna.SaveCommand.Execute(null);
+
+ Assert.False(string.IsNullOrEmpty(anna.ValidationMessage));
+ Assert.Equal(0, reportGrades.SavedCount);
+ }
+
+ [Fact]
+ public void Save_UebersteuernMitBegruendung_AktualisiertFinalDisplay()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var reportGrades = new FakeReportGrades();
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, exams, results);
+ var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
+
+ anna.OverrideValue = "1";
+ anna.OverrideReason = "Mündliche Steigerung im letzten Quartal";
+ anna.SaveCommand.Execute(null);
+
+ Assert.True(string.IsNullOrEmpty(anna.ValidationMessage));
+ Assert.Equal("1", anna.FinalDisplay);
+ Assert.Equal(1, reportGrades.SavedCount);
+ }
+
+ [Fact]
+ public void ToggleLock_FriertDenStandEinUndIgnoriertSpaetereNeuberechnung()
+ {
+ var (exam1, _, exams, results) = BuildExams();
+ var reportGrades = new FakeReportGrades();
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), reportGrades, exams, results);
+ var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
+ anna.OverrideValue = "1";
+ anna.OverrideReason = "Testgrund";
+ anna.SaveCommand.Execute(null);
+
+ anna.ToggleLockCommand.Execute(null);
+
+ // Neue, bessere Klausurnote nachtragen -> würde die Berechnung ändern, wenn ungesperrt.
+ results.Add(new ExamResult { ExamId = exam1.Id, StudentId = Anna.Id, Grade = "1" });
+ vm.SelectedPeriod = vm.PeriodOptions.First(p => p.Period == ParticipationPeriod.H1);
+ vm.SelectedPeriod = vm.PeriodOptions.First(p => p.Period == ParticipationPeriod.FullYear);
+
+ var annaNachSperre = vm.Rows.Single(r => r.StudentId == Anna.Id);
+ Assert.True(annaNachSperre.IsLocked);
+ Assert.Equal("1", annaNachSperre.FinalDisplay);
+ }
+
+ [Fact]
+ public void ExportCsv_EnthaeltAlleSchuelerDerGruppe()
+ {
+ var (_, _, exams, results) = BuildExams();
+ var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results);
+
+ var csv = vm.ExportCsv();
+
+ Assert.Contains("Beispiel, Anna", csv);
+ Assert.Contains("Muster, Ben", csv);
+ }
+
+ [Fact]
+ public void RoundingRule_Paedagogisch_RundetGrenzfallZurBesserenNote()
+ {
+ var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 10), Title = "K1" };
+ var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Date = new DateOnly(2025, 9, 24), Title = "K2" };
+ var exams = new FakeExams([exam1, exam2]);
+ var results = new FakeResults();
+ // Ø exakt 2.5 -> kaufmännisch 3, pädagogisch 2 (bessere Note)
+ results.Add(new ExamResult { ExamId = exam1.Id, StudentId = Anna.Id, Grade = "2" });
+ results.Add(new ExamResult { ExamId = exam2.Id, StudentId = Anna.Id, Grade = "3" });
+ var schemes = new FakeSchemes();
+ schemes.SetForGroup(GroupId, new GradingScheme { ExamsPercent = 100, ParticipationPercent = 0, OtherPercent = 0 });
+ var vm = BuildViewModel(new FakeGrades(), schemes, new FakeReportGrades(), exams, results);
+
+ Assert.Equal("3", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
+
+ vm.RoundingRuleName = "Pädagogisch";
+
+ Assert.Equal("2", vm.Rows.Single(r => r.StudentId == Anna.Id).CalculatedValue);
+ }
+}
diff --git a/LehrerApp.Sync.Tests/ConflictResolverTests.cs b/LehrerApp.Sync.Tests/ConflictResolverTests.cs
new file mode 100644
index 0000000..9a1b590
--- /dev/null
+++ b/LehrerApp.Sync.Tests/ConflictResolverTests.cs
@@ -0,0 +1,177 @@
+using LehrerApp.Sync.Models;
+using Xunit;
+
+namespace LehrerApp.Sync.Tests;
+
+public sealed class ConflictResolverTests
+{
+ // ── Kein Konflikt ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TryResolve_KeinLokalesEreignisFuerDieselbeEntitaet_GibtNullZurueck()
+ {
+ using var temp = new TempEventQueue();
+ var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1");
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void TryResolve_LokalesEreignisFuerAndereEntitaet_ZaehltNichtAlsKonflikt()
+ {
+ using var temp = new TempEventQueue();
+ temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s2"); // andere EntityId
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void TryResolve_LokalesEreignisVomSelbenGeraet_ZaehltNichtAlsKonflikt()
+ {
+ using var temp = new TempEventQueue();
+ temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1"); // gleiche DeviceId
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Null(result);
+ }
+
+ // ── Desktop gewinnt immer gegen Companion ────────────────────────────────
+
+ [Fact]
+ public void TryResolve_LokalDesktopGegenRemoteCompanion_DesktopGewinnt()
+ {
+ using var temp = new TempEventQueue();
+ var local = temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1");
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.NotNull(result);
+ Assert.Equal("LocalWon", result.Resolution);
+ Assert.Equal(local.EventId, result.LocalEvent.EventId);
+ }
+
+ [Fact]
+ public void TryResolve_LokalDesktopGegenRemoteCompanion_LokalesEreignisBleibtInDerQueue()
+ {
+ // Desktop gewinnt -> der lokale (gewinnende) Event wird NICHT bestätigt/entfernt,
+ // da er ja noch zum Server gepusht werden muss.
+ using var temp = new TempEventQueue();
+ temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-1", DeviceType.Companion, "Student", "s1");
+
+ new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Equal(1, temp.Queue.PendingCount());
+ }
+
+ [Fact]
+ public void TryResolve_LokalCompanionGegenRemoteDesktop_DesktopGewinntTrotzAeltererZeit()
+ {
+ using var temp = new TempEventQueue();
+ var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}");
+ // Remote (Desktop) ist zeitlich "älter", gewinnt aber trotzdem, weil Desktop immer gewinnt.
+ var remote = MakeEvent("desktop-1", DeviceType.Desktop, "Student", "s1", timestamp: local.Timestamp.AddMinutes(-5));
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Equal("RemoteWon", result!.Resolution);
+ }
+
+ [Fact]
+ public void TryResolve_RemoteGewinnt_LokalesEreignisWirdAusDerQueueEntfernt()
+ {
+ using var temp = new TempEventQueue();
+ temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("desktop-1", DeviceType.Desktop, "Student", "s1");
+
+ new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Equal(0, temp.Queue.PendingCount());
+ }
+
+ // ── Gleichstand (beide gleicher Gerätetyp): späterer Timestamp gewinnt ──────
+
+ [Fact]
+ public void TryResolve_BeideCompanion_SpaetererTimestampGewinnt()
+ {
+ using var temp = new TempEventQueue();
+ var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-2", DeviceType.Companion, "Student", "s1",
+ timestamp: local.Timestamp.AddMinutes(5));
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Equal("RemoteWon", result!.Resolution);
+ }
+
+ [Fact]
+ public void TryResolve_BeideDesktopGleicherTimestamp_LokalesEreignisGewinntBeiGleichstand()
+ {
+ using var temp = new TempEventQueue();
+ temp.Queue.Enqueue("desktop-1", DeviceType.Desktop, "Student", "s1", "Update", "{}");
+ // Den tatsächlich aus der Queue geladenen (und damit BSON-gerundeten) Zeitstempel
+ // verwenden, sonst wäre ein "exakter" Gleichstand durch Rundung beim Rundtrip nicht
+ // exakt reproduzierbar.
+ var reloadedTimestamp = temp.Queue.GetPending()[0].Timestamp.ToUniversalTime();
+ var remote = MakeEvent("desktop-2", DeviceType.Desktop, "Student", "s1", timestamp: reloadedTimestamp);
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ // ">= " in der Ungleichung: bei exaktem Gleichstand gewinnt das lokale Ereignis.
+ Assert.Equal("LocalWon", result!.Resolution);
+ }
+
+ [Fact]
+ public void TryResolve_BeideCompanionAelteresLokalesEreignis_RemoteGewinntAlsNeuer()
+ {
+ using var temp = new TempEventQueue();
+ var local = temp.Queue.Enqueue("companion-1", DeviceType.Companion, "Student", "s1", "Update", "{}");
+ var remote = MakeEvent("companion-2", DeviceType.Companion, "Student", "s1",
+ timestamp: local.Timestamp.AddSeconds(-1)); // remote ist älter -> lokal (neuer) gewinnt
+
+ var result = new ConflictResolver(temp.Queue).TryResolve(remote, "desktop-1");
+
+ Assert.Equal("LocalWon", result!.Resolution);
+ }
+
+ // ── Hilfsfunktionen ───────────────────────────────────────────────────────
+
+ private static SyncEvent MakeEvent(string deviceId, DeviceType deviceType, string entityType,
+ string entityId, DateTime? timestamp = null) => new()
+ {
+ DeviceId = deviceId,
+ DeviceType = deviceType,
+ EntityType = entityType,
+ EntityId = entityId,
+ Operation = "Update",
+ Payload = "{}",
+ Timestamp = timestamp ?? DateTime.UtcNow,
+ };
+
+ private sealed class TempEventQueue : IDisposable
+ {
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), $"lehrerapp-sync-tests-{Guid.NewGuid():N}");
+ public EventQueue Queue { get; }
+
+ public TempEventQueue()
+ {
+ Directory.CreateDirectory(_directory);
+ Queue = new EventQueue(Path.Combine(_directory, "queue.db"));
+ }
+
+ public void Dispose()
+ {
+ Queue.Dispose();
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+ }
+}
diff --git a/LehrerApp.Sync.Tests/LehrerApp.Sync.Tests.csproj b/LehrerApp.Sync.Tests/LehrerApp.Sync.Tests.csproj
new file mode 100644
index 0000000..c7152e4
--- /dev/null
+++ b/LehrerApp.Sync.Tests/LehrerApp.Sync.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net10.0
+ false
+ true
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/LehrerApp.Sync/ConflictResolver.cs b/LehrerApp.Sync/ConflictResolver.cs
index 294faa1..6c05607 100644
--- a/LehrerApp.Sync/ConflictResolver.cs
+++ b/LehrerApp.Sync/ConflictResolver.cs
@@ -20,7 +20,12 @@ public class ConflictResolver(EventQueue queue)
{
(DeviceType.Desktop, DeviceType.Companion) => local,
(DeviceType.Companion, DeviceType.Desktop) => remote,
- _ => local.Timestamp >= remote.Timestamp ? local : remote,
+ // ToUniversalTime(): LiteDB liefert DateTime beim Auslesen aus der Queue als Kind=Local
+ // zurück (Ticks werden dabei um die lokale Zeitzone verschoben). DateTime-Vergleiche
+ // berücksichtigen Kind nicht, sondern vergleichen nur rohe Ticks — ein direkter Vergleich
+ // von local.Timestamp (Local, aus der Queue) mit remote.Timestamp (Utc, vom Server) wäre
+ // daher außerhalb von UTC+0 falsch.
+ _ => local.Timestamp.ToUniversalTime() >= remote.Timestamp.ToUniversalTime() ? local : remote,
};
if (winner == remote) queue.Acknowledge([local.EventId]);
diff --git a/LehrerApp.Sync/Models/SyncModels.cs b/LehrerApp.Sync/Models/SyncModels.cs
index 896bff7..1145634 100644
--- a/LehrerApp.Sync/Models/SyncModels.cs
+++ b/LehrerApp.Sync/Models/SyncModels.cs
@@ -1,3 +1,5 @@
+using LiteDB;
+
namespace LehrerApp.Sync.Models;
// ── Events ────────────────────────────────────────────────────────────────────
@@ -5,6 +7,10 @@ namespace LehrerApp.Sync.Models;
/// Desktop-Event: Payload ist AES-256-GCM verschlüsselt.
public class SyncEvent
{
+ // LiteDB erkennt nur eine Property namens "Id" automatisch als Primärschlüssel.
+ // Ohne [BsonId] würde ein eigener, von EventId unabhängiger _id vergeben, wodurch
+ // EventQueue.Acknowledge()/_queue.Delete(EventId) niemals etwas löschen würde.
+ [BsonId]
public Guid EventId { get; init; } = Guid.NewGuid();
public string DeviceId { get; init; } = "";
public DeviceType DeviceType { get; init; }
diff --git a/LehrerApp.Tests/GradingServiceTests.cs b/LehrerApp.Tests/GradingServiceTests.cs
new file mode 100644
index 0000000..9752a7c
--- /dev/null
+++ b/LehrerApp.Tests/GradingServiceTests.cs
@@ -0,0 +1,319 @@
+using LehrerApp.Core.Models;
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public class GradingServiceTests
+{
+ private readonly GradingService _sut = new();
+
+ // ── CalculateGrade: Notenschlüssel-Grenzfälle ────────────────────────────
+
+ [Theory]
+ [InlineData(87.5, 100, "1")] // exakt an der Grenze zählt für die bessere Note
+ [InlineData(87.4, 100, "2")] // knapp darunter
+ [InlineData(100, 100, "1")] // Maximalpunktzahl
+ [InlineData(50, 100, "4")]
+ [InlineData(0, 100, "6")] // Minimum, exakt an der untersten Grenze
+ public void CalculateGrade_Grades1To6_Grenzwerte(double achieved, double maximum, string expected)
+ {
+ var result = _sut.CalculateGrade(achieved, maximum, GradingService.DefaultKey1To6());
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void CalculateGrade_MaximumNull_GibtPlatzhalterZurueck()
+ {
+ var result = _sut.CalculateGrade(5, 0, GradingService.DefaultKey1To6());
+ Assert.Equal("-", result);
+ }
+
+ [Fact]
+ public void CalculateGrade_MaximumNegativ_GibtPlatzhalterZurueck()
+ {
+ var result = _sut.CalculateGrade(5, -10, GradingService.DefaultKey1To6());
+ Assert.Equal("-", result);
+ }
+
+ [Fact]
+ public void CalculateGrade_UeberschreitetMaximum_GibtBesteNote()
+ {
+ // z.B. Bonuspunkte über die maximale Punktzahl hinaus
+ var result = _sut.CalculateGrade(120, 100, GradingService.DefaultKey1To6());
+ Assert.Equal("1", result);
+ }
+
+ [Fact]
+ public void CalculateGrade_NegativeErreichtePunkte_FaelltAufSchlechtesteNoteZurueck()
+ {
+ // Kein Schlüsseleintrag deckt einen negativen Prozentsatz ab -> Fallback auf die
+ // Stufe mit der niedrigsten Grenze (schlechteste Note), nicht auf einen Crash.
+ var result = _sut.CalculateGrade(-5, 100, GradingService.DefaultKey1To6());
+ Assert.Equal("6", result);
+ }
+
+ [Fact]
+ public void CalculateGrade_Punkte0Bis15_Grenzwerte()
+ {
+ var key = GradingService.DefaultKey0To15();
+ Assert.Equal("15", _sut.CalculateGrade(95, 100, key));
+ Assert.Equal("14", _sut.CalculateGrade(94.9, 100, key));
+ Assert.Equal("0", _sut.CalculateGrade(0, 100, key));
+ }
+
+ [Fact]
+ public void CalculateGrade_UnsortierterSchluessel_FunktioniertGenauso()
+ {
+ var shuffled = GradingService.DefaultKey1To6().OrderBy(_ => Guid.NewGuid()).ToList();
+ var result = _sut.CalculateGrade(80, 100, shuffled);
+ Assert.Equal("2", result);
+ }
+
+ // ── ValidateGradingKey ────────────────────────────────────────────────────
+
+ [Fact]
+ public void ValidateGradingKey_Standardschluessel1To6_IstGueltig()
+ {
+ Assert.Null(_sut.ValidateGradingKey(GradingService.DefaultKey1To6()));
+ }
+
+ [Fact]
+ public void ValidateGradingKey_Standardschluessel0To15_IstGueltig()
+ {
+ Assert.Null(_sut.ValidateGradingKey(GradingService.DefaultKey0To15()));
+ }
+
+ [Fact]
+ public void ValidateGradingKey_WenigerAlsZweiStufen_LiefertFehler()
+ {
+ var error = _sut.ValidateGradingKey([new GradingKeyEntry { Grade = "1", MinPercent = 0 }]);
+ Assert.NotNull(error);
+ }
+
+ [Fact]
+ public void ValidateGradingKey_LeereBezeichnung_LiefertFehler()
+ {
+ var error = _sut.ValidateGradingKey(
+ [
+ new() { Grade = "", MinPercent = 50 },
+ new() { Grade = "6", MinPercent = 0 },
+ ]);
+ Assert.NotNull(error);
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(101)]
+ public void ValidateGradingKey_ProzentgrenzeAusserhalbBereich_LiefertFehler(double minPercent)
+ {
+ var error = _sut.ValidateGradingKey(
+ [
+ new() { Grade = "1", MinPercent = minPercent },
+ new() { Grade = "6", MinPercent = 0 },
+ ]);
+ Assert.NotNull(error);
+ }
+
+ [Fact]
+ public void ValidateGradingKey_DoppelteProzentgrenze_LiefertFehler()
+ {
+ var error = _sut.ValidateGradingKey(
+ [
+ new() { Grade = "1", MinPercent = 50 },
+ new() { Grade = "2", MinPercent = 50 },
+ new() { Grade = "6", MinPercent = 0 },
+ ]);
+ Assert.NotNull(error);
+ }
+
+ [Fact]
+ public void ValidateGradingKey_UntersteStufeNichtBeiNull_LiefertFehler()
+ {
+ var error = _sut.ValidateGradingKey(
+ [
+ new() { Grade = "1", MinPercent = 50 },
+ new() { Grade = "6", MinPercent = 10 },
+ ]);
+ Assert.NotNull(error);
+ }
+
+ // ── WeightedAverage: gewichteter Schnitt ─────────────────────────────────
+
+ [Fact]
+ public void WeightedAverage_GleicheGewichte_EntsprichtEinfachemMittel()
+ {
+ var result = _sut.WeightedAverage([("2", 1.0), ("4", 1.0)]);
+ Assert.Equal(3.0, result, precision: 6);
+ }
+
+ [Fact]
+ public void WeightedAverage_UnterschiedlicheGewichte_GewichtetKorrekt()
+ {
+ // Note 1 zählt doppelt so stark wie Note 4 -> (1*2 + 4*1) / 3 = 2
+ var result = _sut.WeightedAverage([("1", 2.0), ("4", 1.0)]);
+ Assert.Equal(2.0, result, precision: 6);
+ }
+
+ [Fact]
+ public void WeightedAverage_NichtNumerischeWerte_WerdenIgnoriert()
+ {
+ var result = _sut.WeightedAverage([("2", 1.0), ("entfällt", 1.0), ("4", 1.0)]);
+ Assert.Equal(3.0, result, precision: 6);
+ }
+
+ [Fact]
+ public void WeightedAverage_LeereListe_GibtNullZurueck()
+ {
+ Assert.Equal(0, _sut.WeightedAverage([]));
+ }
+
+ [Fact]
+ public void WeightedAverage_NurNichtNumerischeWerte_GibtNullZurueck()
+ {
+ Assert.Equal(0, _sut.WeightedAverage([("entfällt", 1.0), ("krank", 1.0)]));
+ }
+
+ [Fact]
+ public void WeightedAverage_GewichtssummeNull_GibtNullZurueckStattDivisionDurchNull()
+ {
+ var result = _sut.WeightedAverage([("2", 0.0), ("4", 0.0)]);
+ Assert.Equal(0, result);
+ }
+
+ // ── RoundToGrade ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void RoundToGrade_Kaufmaennisch_RundetHalbeImmerVomNullpunktWeg()
+ {
+ Assert.Equal("3", _sut.RoundToGrade(2.5, GradingSystem.Grades1To6, RoundingRule.Commercial));
+ }
+
+ [Fact]
+ public void RoundToGrade_Paedagogisch_RundetBeiNotenHalbeZurBesserenNote()
+ {
+ // Grades1To6: kleinere Zahl ist besser -> bei genau 2.5 wird abgerundet auf 2
+ Assert.Equal("2", _sut.RoundToGrade(2.5, GradingSystem.Grades1To6, RoundingRule.Pedagogical));
+ }
+
+ [Fact]
+ public void RoundToGrade_Paedagogisch_RundetBeiPunktenHalbeZurBesserenNote()
+ {
+ // Points0To15: größere Zahl ist besser -> bei genau 10.5 wird aufgerundet auf 11
+ Assert.Equal("11", _sut.RoundToGrade(10.5, GradingSystem.Points0To15, RoundingRule.Pedagogical));
+ }
+
+ [Fact]
+ public void RoundToGrade_Paedagogisch_OhneGenauenHalbenWert_VerhaeltSichWieKaufmaennisch()
+ {
+ Assert.Equal("2", _sut.RoundToGrade(2.3, GradingSystem.Grades1To6, RoundingRule.Pedagogical));
+ Assert.Equal("3", _sut.RoundToGrade(2.7, GradingSystem.Grades1To6, RoundingRule.Pedagogical));
+ }
+
+ [Fact]
+ public void RoundToGrade_WertUnterhalbDesBereichs_WirdAufMinimumBegrenzt()
+ {
+ Assert.Equal("1", _sut.RoundToGrade(-3, GradingSystem.Grades1To6, RoundingRule.Commercial));
+ Assert.Equal("0", _sut.RoundToGrade(-3, GradingSystem.Points0To15, RoundingRule.Commercial));
+ }
+
+ [Fact]
+ public void RoundToGrade_WertOberhalbDesBereichs_WirdAufMaximumBegrenzt()
+ {
+ Assert.Equal("6", _sut.RoundToGrade(9, GradingSystem.Grades1To6, RoundingRule.Commercial));
+ Assert.Equal("15", _sut.RoundToGrade(20, GradingSystem.Points0To15, RoundingRule.Commercial));
+ }
+
+ // ── ParticipationGrade ────────────────────────────────────────────────────
+
+ [Fact]
+ public void ParticipationGrade_MinimaleBewertung_GibtSchlechtesteNote()
+ {
+ Assert.Equal("6", _sut.ParticipationGrade(-2, GradingSystem.Grades1To6));
+ Assert.Equal("0", _sut.ParticipationGrade(-2, GradingSystem.Points0To15));
+ }
+
+ [Fact]
+ public void ParticipationGrade_MaximaleBewertung_GibtBesteNote()
+ {
+ Assert.Equal("1", _sut.ParticipationGrade(2, GradingSystem.Grades1To6));
+ Assert.Equal("15", _sut.ParticipationGrade(2, GradingSystem.Points0To15));
+ }
+
+ [Fact]
+ public void ParticipationGrade_MittlereBewertung_GibtMittlereNote()
+ {
+ Assert.Equal("4", _sut.ParticipationGrade(0, GradingSystem.Grades1To6));
+ }
+
+ // ── ValidateGradingScheme ────────────────────────────────────────────────
+
+ [Fact]
+ public void ValidateGradingScheme_SummeHundert_IstGueltig()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ Assert.Null(_sut.ValidateGradingScheme(scheme));
+ }
+
+ [Fact]
+ public void ValidateGradingScheme_SummeUngleichHundert_LiefertFehler()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 20 };
+ Assert.NotNull(_sut.ValidateGradingScheme(scheme));
+ }
+
+ [Fact]
+ public void ValidateGradingScheme_NegativerAnteil_LiefertFehler()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 110, ParticipationPercent = 0, OtherPercent = -10 };
+ Assert.NotNull(_sut.ValidateGradingScheme(scheme));
+ }
+
+ // ── CalculateReportGrade ──────────────────────────────────────────────────
+
+ [Fact]
+ public void CalculateReportGrade_AlleBereicheVorhanden_GewichtetGemaessSchema()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ // Klausuren Ø2, Mitarbeit Ø3, Sonstige Ø1 -> (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
+ var result = _sut.CalculateReportGrade(
+ examGrades: [("2", 1)],
+ participationGrades: [("3", 1)],
+ otherGrades: [("1", 1)],
+ scheme, GradingSystem.Grades1To6, RoundingRule.Commercial);
+ Assert.Equal("2", result);
+ }
+
+ [Fact]
+ public void CalculateReportGrade_FehlenderBereich_NormiertVerbleibendeAnteile()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ // Keine sonstigen Noten -> nur Klausuren (50) und Mitarbeit (40) zählen, neu normiert auf 90.
+ // (2*50 + 4*40) / 90 = 2.888... -> kaufmännisch 3
+ var result = _sut.CalculateReportGrade(
+ examGrades: [("2", 1)],
+ participationGrades: [("4", 1)],
+ otherGrades: [],
+ scheme, GradingSystem.Grades1To6, RoundingRule.Commercial);
+ Assert.Equal("3", result);
+ }
+
+ [Fact]
+ public void CalculateReportGrade_KeineWerteInIrgendeinemBereich_GibtNullZurueck()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
+ var result = _sut.CalculateReportGrade([], [], [], scheme, GradingSystem.Grades1To6, RoundingRule.Commercial);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void CalculateReportGrade_RundungsregelWirdBeruecksichtigt()
+ {
+ var scheme = new GradingScheme { ExamsPercent = 100, ParticipationPercent = 0, OtherPercent = 0 };
+ // Klausuren-Durchschnitt exakt 2.5 -> kaufmännisch 3, pädagogisch 2
+ var kaufmaennisch = _sut.CalculateReportGrade([("2", 1), ("3", 1)], [], [], scheme, GradingSystem.Grades1To6, RoundingRule.Commercial);
+ var paedagogisch = _sut.CalculateReportGrade([("2", 1), ("3", 1)], [], [], scheme, GradingSystem.Grades1To6, RoundingRule.Pedagogical);
+ Assert.Equal("3", kaufmaennisch);
+ Assert.Equal("2", paedagogisch);
+ }
+}
diff --git a/LehrerApp.Tests/SchoolYearServiceTests.cs b/LehrerApp.Tests/SchoolYearServiceTests.cs
new file mode 100644
index 0000000..df3138f
--- /dev/null
+++ b/LehrerApp.Tests/SchoolYearServiceTests.cs
@@ -0,0 +1,110 @@
+using LehrerApp.Core.Services;
+using Xunit;
+
+namespace LehrerApp.Tests;
+
+public class SchoolYearServiceTests
+{
+ private readonly SchoolYearService _sut = new();
+
+ // ── FormatSchoolYear ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void FormatSchoolYear_NormalerJahrgang()
+ {
+ Assert.Equal("2025/26", _sut.FormatSchoolYear(2025));
+ }
+
+ [Fact]
+ public void FormatSchoolYear_JahrhundertwechselWirdZweistelligDargestellt()
+ {
+ // 1999 -> 2000, aber zweistellig als "00", nicht "100" oder leer.
+ Assert.Equal("1999/00", _sut.FormatSchoolYear(1999));
+ }
+
+ // ── CurrentSchoolYear: Jahreswechsel am 1. August ────────────────────────
+
+ [Fact]
+ public void CurrentSchoolYear_AmSchuljahreswechsel_ZaehltBereitsDasNeueJahr()
+ {
+ // 1. August ist der erste Tag des neuen Schuljahres.
+ var result = _sut.CurrentSchoolYear(new DateOnly(2025, 8, 1));
+ Assert.Equal("2025/26", result);
+ }
+
+ [Fact]
+ public void CurrentSchoolYear_TagVorDemWechsel_GehoertNochZumAltenSchuljahr()
+ {
+ // 31. Juli ist der letzte Tag des alten Schuljahres.
+ var result = _sut.CurrentSchoolYear(new DateOnly(2025, 7, 31));
+ Assert.Equal("2024/25", result);
+ }
+
+ [Fact]
+ public void CurrentSchoolYear_MittenImHerbst_GehoertZumLaufendenSchuljahr()
+ {
+ var result = _sut.CurrentSchoolYear(new DateOnly(2025, 11, 15));
+ Assert.Equal("2025/26", result);
+ }
+
+ [Fact]
+ public void CurrentSchoolYear_ImJanuar_GehoertNochZumImVorjahrBegonnenenSchuljahr()
+ {
+ // Der Kalenderjahreswechsel (1. Januar) ist NICHT der Schuljahreswechsel.
+ var result = _sut.CurrentSchoolYear(new DateOnly(2026, 1, 15));
+ Assert.Equal("2025/26", result);
+ }
+
+ [Fact]
+ public void CurrentSchoolYear_OhneParameter_VerwendetHeutigesDatum()
+ {
+ var expectedStartYear = DateTime.Today.Month >= 8 ? DateTime.Today.Year : DateTime.Today.Year - 1;
+ Assert.Equal(_sut.FormatSchoolYear(expectedStartYear), _sut.CurrentSchoolYear());
+ }
+
+ // ── SchoolYearStart / SchoolYearEnd ──────────────────────────────────────
+
+ [Fact]
+ public void SchoolYearStart_ErsterAugust()
+ {
+ Assert.Equal(new DateOnly(2025, 8, 1), _sut.SchoolYearStart("2025/26"));
+ }
+
+ [Fact]
+ public void SchoolYearEnd_EinunddreissigsterJuliDesFolgejahres()
+ {
+ Assert.Equal(new DateOnly(2026, 7, 31), _sut.SchoolYearEnd("2025/26"));
+ }
+
+ [Fact]
+ public void SchoolYearStartUndEnd_SchliessenLueckenlosAneinanderAn()
+ {
+ // Kein Tag darf zwischen zwei aufeinanderfolgenden Schuljahren fehlen oder sich überlappen.
+ var endeAlt = _sut.SchoolYearEnd("2025/26");
+ var startNeu = _sut.SchoolYearStart("2026/27");
+ Assert.Equal(startNeu, endeAlt.AddDays(1));
+ }
+
+ // ── RecentSchoolYears ─────────────────────────────────────────────────────
+
+ [Fact]
+ public void RecentSchoolYears_GibtAnzahlWieAngefordertZurueck()
+ {
+ var result = _sut.RecentSchoolYears(3, new DateOnly(2025, 9, 1));
+ Assert.Equal(["2025/26", "2024/25", "2023/24"], result);
+ }
+
+ [Fact]
+ public void RecentSchoolYears_StandardanzahlIstFuenf()
+ {
+ var result = _sut.RecentSchoolYears(today: new DateOnly(2025, 9, 1));
+ Assert.Equal(5, result.Count);
+ }
+
+ [Fact]
+ public void RecentSchoolYears_ErsterEintragIstDasAktuelleSchuljahr()
+ {
+ var today = new DateOnly(2025, 3, 1); // Januar-Fall: gehört noch zu 2024/25
+ Assert.Equal(_sut.CurrentSchoolYear(today), _sut.RecentSchoolYears(today: today)[0]);
+ }
+}
diff --git a/LehrerApp.sln b/LehrerApp.sln
index 15087a2..403d2be 100644
--- a/LehrerApp.sln
+++ b/LehrerApp.sln
@@ -14,6 +14,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data.Tests", "Leh
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Tests", "LehrerApp.Tests\LehrerApp.Tests.csproj", "{4A5AC233-5976-44CD-B5F7-6562B096BA47}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop.Tests", "LehrerApp.Desktop.Tests\LehrerApp.Desktop.Tests.csproj", "{9A422EEC-6F1E-4510-91B4-F893B1B32445}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -96,6 +100,30 @@ Global
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|x64.Build.0 = Release|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|x86.ActiveCfg = Release|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|x86.Build.0 = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|x64.Build.0 = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Debug|x86.Build.0 = Debug|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|x64.ActiveCfg = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|x64.Build.0 = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|x86.ActiveCfg = Release|Any CPU
+ {9A422EEC-6F1E-4510-91B4-F893B1B32445}.Release|x86.Build.0 = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|x64.Build.0 = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Debug|x86.Build.0 = Debug|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x64.ActiveCfg = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x64.Build.0 = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = Release|Any CPU
+ {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/TODO.md b/TODO.md
index e1ac189..bc9eff3 100644
--- a/TODO.md
+++ b/TODO.md
@@ -452,11 +452,43 @@ Fächer- und Kompetenzverwaltung existiert bereits in
- [x] **13.1.1** Testprojekt `LehrerApp.Tests` angelegt (referenziert `LehrerApp.Core`, analog zu
[LehrerApp.Data.Tests](LehrerApp.Data.Tests/LehrerApp.Data.Tests.csproj) für die Datenschicht).
Noch ohne Testfälle — folgt mit 13.1.2ff.
-- [ ] **13.1.2** Unit-Tests für `GradingService` (Notenschlüssel-Grenzfälle, gewichteter Schnitt).
-- [ ] **13.1.3** Unit-Tests für `SchoolYearService` (Jahreswechsel, Halbjahresgrenze).
-- [ ] **13.1.4** Repository-Tests gegen eine In-Memory-LiteDB.
-- [ ] **13.1.5** Tests für die Mitarbeits-Aggregation (3.2) und Zeugnisnotenberechnung (2.4).
-- [ ] **13.1.6** Tests für `ConflictResolver`.
+- [x] **13.1.2** Unit-Tests für `GradingService` (Notenschlüssel-Grenzfälle, gewichteter Schnitt) —
+ 41 Tests in [GradingServiceTests.cs](LehrerApp.Tests/GradingServiceTests.cs), deckt
+ `CalculateGrade`, `ValidateGradingKey`, `WeightedAverage`, `RoundToGrade`, `ParticipationGrade`,
+ `ValidateGradingScheme` und `CalculateReportGrade` ab.
+- [x] **13.1.3** Unit-Tests für `SchoolYearService` (Jahreswechsel, Schuljahresgrenze) —
+ 13 Tests in [SchoolYearServiceTests.cs](LehrerApp.Tests/SchoolYearServiceTests.cs).
+ Dafür `CurrentSchoolYear`/`RecentSchoolYears` um ein optionales `today`-Parameter
+ erweitert (Default weiterhin `DateTime.Today`), um den 1.-August-Wechsel deterministisch
+ zu testen, ohne bestehende Aufrufer zu ändern.
+- [x] **13.1.4** Repository-Tests gegen eine In-Memory-LiteDB — `LiteDbContext` um einen
+ `Stream`-Konstruktor erweitert (`new LiteDatabase(stream)`, kein Datei-I/O nötig),
+ 19 Tests in [RepositoryTests.cs](LehrerApp.Data.Tests/RepositoryTests.cs): u.a.
+ Kaskadenlöschung bei `GroupRepository.Delete`/`ExamRepository.Delete`/
+ `ParticipationSessionRepository.Delete`, Eindeutigkeitsprüfungen (`GroupMembership`,
+ `ParticipationEntry`), Fach-Validierung (Trimmen, Duplikate, Löschsperre bei Verwendung),
+ `GradingScheme`-Auflösung gruppenspezifisch vs. Voreinstellung.
+- [x] **13.1.5** Tests für die Mitarbeits-Aggregation (3.2) und Zeugnisnotenberechnung (2.4) —
+ neues Testprojekt `LehrerApp.Desktop.Tests` (referenziert `LehrerApp.Desktop`, damit
+ ViewModel-Logik ohne echtes UI getestet werden kann), gemeinsame Fakes in
+ [Fakes.cs](LehrerApp.Desktop.Tests/Fakes.cs). 9 Tests für
+ [ParticipationGradeAggregationTests.cs](LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs)
+ (gewichtetes Mittel, "nicht bewertet" ≠ schlechte Note, Trend, Aspektgewicht 0, Halbjahresfilter,
+ Übernehmen-Update-statt-Duplikat) und 9 für
+ [ReportGradeCalculationTests.cs](LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs)
+ (Schema-Auflösung gruppenspezifisch/Voreinstellung/Fallback, Pflicht-Begründung beim
+ Übersteuern, Festschreiben friert den Stand ein, Rundungsregel).
+- [x] **13.1.6** Tests für `ConflictResolver` — neues Testprojekt `LehrerApp.Sync.Tests`,
+ 10 Tests in [ConflictResolverTests.cs](LehrerApp.Sync.Tests/ConflictResolverTests.cs)
+ (Desktop schlägt Companion immer, Gleichstand nach Zeitstempel, kein Konflikt bei
+ unterschiedlicher Entität/gleichem Gerät). Dabei zwei echte Fehler gefunden und behoben:
+ (1) `SyncEvent` hatte keine `[BsonId]`-Markierung auf `EventId` — LiteDB vergab dadurch
+ einen eigenen, unabhängigen `_id`, wodurch `EventQueue.Acknowledge()` nie etwas aus der
+ Queue löschte (verlorene Konflikte blieben für immer offen). (2) `ConflictResolver` verglich
+ `local.Timestamp` (aus LiteDB, `Kind=Local` mit verschobenen Ticks) direkt mit
+ `remote.Timestamp` (`Kind=Utc`) — `DateTime`-Vergleiche ignorieren `Kind` und vergleichen
+ nur rohe Ticks, wodurch die Gleichstand-Regel außerhalb von UTC+0 falsch entschied. Fix:
+ `.ToUniversalTime()` auf beiden Seiten vor dem Vergleich.
### 13.2 Fehlerbehandlung & Logging
- [ ] **13.2.1** Zentrale Exception-Behandlung mit verständlicher Fehlermeldung statt Absturz.