2 Commits
Author SHA1 Message Date
admin 273f459119 Testabdeckung Kapitel 13.1 (Tests, Fehlerbehandlung)
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.
2026-08-13 11:05:06 +02:00
admin a8b8a40b52 Testprojekt LehrerApp.Tests anlegen (13.1.1)
Neues xUnit-Projekt für die Core-Schicht, analog zu LehrerApp.Data.Tests.
Noch ohne Testfälle.
2026-08-13 10:34:41 +02:00
16 changed files with 1667 additions and 35 deletions
+13 -8
View File
@@ -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);
}
/// <param name="today">Nur für Tests — Standard ist das heutige Datum.</param>
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<string> RecentSchoolYears(int count = 5)
/// <param name="today">Nur für Tests — Standard ist das heutige Datum.</param>
public List<string> 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;
}
+334
View File
@@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<LiteDB.LiteException>(() =>
db.ParticipationEntries.Insert(new ParticipationEntry { SessionId = sessionId, StudentId = studentId }));
}
}
+8
View File
@@ -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<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
+127
View File
@@ -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<Student> all) : IStudentRepository
{
public Student? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public List<Student> GetAll(bool includeInactive = false) => all;
public List<Student> GetByGroup(Guid groupId) => all;
public void Save(Student student) { }
public void Delete(Guid id) { }
}
public class FakeMemberships(List<GroupMembership> all) : IGroupMembershipRepository
{
public List<GroupMembership> GetByStudent(Guid studentId) => all.Where(m => m.StudentId == studentId).ToList();
public List<GroupMembership> 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<ParticipationSession> all) : IParticipationSessionRepository
{
public List<ParticipationSession> 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<ParticipationEntry> _all = [];
public void Add(ParticipationEntry e) => _all.Add(e);
public List<ParticipationEntry> GetBySession(Guid sessionId) => _all.Where(e => e.SessionId == sessionId).ToList();
public List<ParticipationEntry> 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<ParticipationEntry> 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<ParticipationAspect> GetDefaults() => [];
public List<ParticipationAspect> GetByGroup(Guid groupId) => [];
public void Save(ParticipationAspect aspect) { }
public void Delete(Guid id) { }
}
public class FakeGrades : IGradeRepository
{
private readonly List<Grade> _all = [];
public void Add(Grade g) => _all.Add(g);
public List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId) =>
_all.Where(g => g.StudentId == studentId && g.GroupId == groupId).ToList();
public List<Grade> 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<Exam> all) : IExamRepository
{
public Exam? GetById(Guid id) => all.FirstOrDefault(e => e.Id == id);
public List<Exam> 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<ExamResult> _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<ExamResult> GetByExam(Guid examId) => _all.Where(r => r.ExamId == examId).ToList();
public List<ExamResult> 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<ExamResult> results) { }
}
public class FakeSchemes : IGradingSchemeRepository
{
private readonly Dictionary<Guid, GradingScheme> _byGroup = [];
private readonly Dictionary<GroupType, GradingScheme> _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<ReportGrade> _all = [];
public int SavedCount { get; private set; }
public List<ReportGrade> 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);
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Desktop\LehrerApp.Desktop.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+6 -1
View File
@@ -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]);
+6
View File
@@ -1,3 +1,5 @@
using LiteDB;
namespace LehrerApp.Sync.Models;
// ── Events ────────────────────────────────────────────────────────────────────
@@ -5,6 +7,10 @@ namespace LehrerApp.Sync.Models;
/// <summary>Desktop-Event: Payload ist AES-256-GCM verschlüsselt.</summary>
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; }
+319
View File
@@ -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);
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+110
View File
@@ -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]);
}
}
+98 -1
View File
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Core", "LehrerApp.Core\LehrerApp.Core.csproj", "{A1000001-0000-0000-0000-000000000001}"
EndProject
@@ -12,23 +12,120 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "Lehrer
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data.Tests", "LehrerApp.Data.Tests\LehrerApp.Data.Tests.csproj", "{A1000006-0000-0000-0000-000000000006}"
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
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU
{A1000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x64.Build.0 = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|x86.Build.0 = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x64.ActiveCfg = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x64.Build.0 = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x86.ActiveCfg = Release|Any CPU
{A1000002-0000-0000-0000-000000000002}.Release|x86.Build.0 = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x64.Build.0 = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|x86.Build.0 = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x64.ActiveCfg = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x64.Build.0 = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x86.ActiveCfg = Release|Any CPU
{A1000003-0000-0000-0000-000000000003}.Release|x86.Build.0 = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x64.Build.0 = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|x86.Build.0 = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x64.ActiveCfg = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x64.Build.0 = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x86.ActiveCfg = Release|Any CPU
{A1000004-0000-0000-0000-000000000004}.Release|x86.Build.0 = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x64.Build.0 = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|x86.Build.0 = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x64.ActiveCfg = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x64.Build.0 = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x86.ActiveCfg = Release|Any CPU
{A1000005-0000-0000-0000-000000000005}.Release|x86.Build.0 = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x64.Build.0 = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Debug|x86.Build.0 = Debug|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x64.ActiveCfg = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x64.Build.0 = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x86.ActiveCfg = Release|Any CPU
{A1000006-0000-0000-0000-000000000006}.Release|x86.Build.0 = Release|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|x64.ActiveCfg = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|x64.Build.0 = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|x86.ActiveCfg = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Debug|x86.Build.0 = Debug|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|Any CPU.Build.0 = Release|Any CPU
{4A5AC233-5976-44CD-B5F7-6562B096BA47}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
EndGlobal
+40 -7
View File
@@ -449,13 +449,46 @@ Fächer- und Kompetenzverwaltung existiert bereits in
## 13. Technische Basis
### 13.1 Tests
- [ ] **13.1.1** Testprojekt `LehrerApp.Tests` anlegen (xunit-Pakete sind in
[Directory.Packages.props](Directory.Packages.props) bereits gepflegt, Projekt fehlt).
- [ ] **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.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.
- [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.