From 273f4591192fc6e84283abb9a1c6033695a2f3f1 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Thu, 13 Aug 2026 11:05:06 +0200 Subject: [PATCH] Testabdeckung Kapitel 13.1 (Tests, Fehlerbehandlung) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier neue xUnit-Testprojekte (LehrerApp.Tests, .Data.Tests, .Desktop.Tests, .Sync.Tests) mit zusammen 104 Tests: GradingService, SchoolYearService, Repositories gegen In-Memory-LiteDB, Mitarbeits-Aggregation (3.2), Zeugnisnotenberechnung (2.4) und ConflictResolver. Dabei zwei echte Fehler in der Sync-Schicht gefunden und behoben: - SyncEvent.EventId fehlte [BsonId], wodurch EventQueue.Acknowledge() nie etwas aus der Queue löschte. - ConflictResolver verglich Zeitstempel unterschiedlicher DateTimeKind direkt (LiteDB liefert Local statt Utc zurück), was die Gleichstand- Regel außerhalb von UTC+0 verfälschte. SchoolYearService.CurrentSchoolYear()/RecentSchoolYears() um ein optionales today-Argument erweitert, um den Schuljahreswechsel deterministisch zu testen; LiteDbContext um einen Stream-Konstruktor für In-Memory-Tests. --- LehrerApp.Core/Services/SchoolYearService.cs | 21 +- LehrerApp.Data.Tests/RepositoryTests.cs | 334 ++++++++++++++++++ LehrerApp.Data/LiteDbContext.cs | 8 + LehrerApp.Desktop.Tests/Fakes.cs | 127 +++++++ .../LehrerApp.Desktop.Tests.csproj | 18 + .../ParticipationGradeAggregationTests.cs | 160 +++++++++ .../ReportGradeCalculationTests.cs | 197 +++++++++++ LehrerApp.Sync.Tests/ConflictResolverTests.cs | 177 ++++++++++ .../LehrerApp.Sync.Tests.csproj | 18 + LehrerApp.Sync/ConflictResolver.cs | 7 +- LehrerApp.Sync/Models/SyncModels.cs | 6 + LehrerApp.Tests/GradingServiceTests.cs | 319 +++++++++++++++++ LehrerApp.Tests/SchoolYearServiceTests.cs | 110 ++++++ LehrerApp.sln | 28 ++ TODO.md | 42 ++- 15 files changed, 1558 insertions(+), 14 deletions(-) create mode 100644 LehrerApp.Data.Tests/RepositoryTests.cs create mode 100644 LehrerApp.Desktop.Tests/Fakes.cs create mode 100644 LehrerApp.Desktop.Tests/LehrerApp.Desktop.Tests.csproj create mode 100644 LehrerApp.Desktop.Tests/ParticipationGradeAggregationTests.cs create mode 100644 LehrerApp.Desktop.Tests/ReportGradeCalculationTests.cs create mode 100644 LehrerApp.Sync.Tests/ConflictResolverTests.cs create mode 100644 LehrerApp.Sync.Tests/LehrerApp.Sync.Tests.csproj create mode 100644 LehrerApp.Tests/GradingServiceTests.cs create mode 100644 LehrerApp.Tests/SchoolYearServiceTests.cs 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.