Files
LehrerApp/LehrerApp.Data.Tests/LiteDbContextTests.cs
T
adminandClaude Sonnet 5 dc2f58c4dc feat: Kompetenzzuordnungen von Freitext-Code auf stabile CompetencyItem.Id umgestellt (TODO 16.5)
Unit/Lesson/ExamTask/ParticipationSession referenzierten Kompetenzen bisher per Freitext-Code,
case-insensitiv gegen den Katalog gematcht - fragil bei Umbenennungen und seit der
Bildungsplan-Versionierung nicht mehr eindeutig (zwei Versionen können denselben Code vergeben).
Neuer Werttyp CompetencyReference (ItemId + Code/Description-Momentaufnahme) löst das: solange die
Id noch im Katalog auflösbar ist, zählt die live Fassung (Rename wirkt sofort), sonst bleibt die
gespeicherte historische Fassung sichtbar statt spurlos zu verschwinden.

Migration (Schema-Version 6) löst bestehende Codes gegen den Katalog auf; die zuvor dreifach
duplizierte Kompetenz-Tag-UI ist jetzt in CompetencyTagGroupBuilder konsolidiert.
add_lesson_competency/remove_lesson_competency nehmen jetzt itemId statt code und validieren gegen
den Katalog (vorher ungeprüft).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 00:41:54 +02:00

431 lines
18 KiB
C#

using LehrerApp.Core.Models;
using LiteDB;
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class LiteDbContextTests
{
[Fact]
public void MigratesLegacyEnrollmentAndSubjectTextWithoutLosingData()
{
using var temp = new TempDatabase();
var groupId = Guid.NewGuid();
var studentId = Guid.NewGuid();
var membershipId = Guid.NewGuid();
var examId = Guid.NewGuid();
var gradeId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var addedOn = new DateOnly(2026, 6, 23);
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<Subject>("subjects").Insert(new Subject { Name = "mathematik" });
legacy.GetCollection<BsonDocument>("groups").Insert(new BsonDocument
{
["_id"] = groupId,
[nameof(LearningGroup.Name)] = "Mathe G",
["Subject"] = "Mathematik",
[nameof(LearningGroup.SchoolYear)] = "2025/26",
});
legacy.GetCollection<BsonDocument>("enrollments").Insert(new BsonDocument
{
["_id"] = membershipId,
[nameof(GroupMembership.StudentId)] = studentId,
[nameof(GroupMembership.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
["EnrolledAt"] = BsonMapper.Global.Serialize(addedOn),
});
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
{
["_id"] = examId,
[nameof(Exam.GroupId)] = groupId,
["Subject"] = "Mathematik",
});
legacy.GetCollection<BsonDocument>("grades").Insert(new BsonDocument
{
["_id"] = gradeId,
[nameof(Grade.StudentId)] = studentId,
[nameof(Grade.GroupId)] = groupId,
["SchoolYear"] = "2025/26",
});
legacy.GetCollection<BsonDocument>("participation").Insert(new BsonDocument
{
["_id"] = entryId,
[nameof(ParticipationEntry.SessionId)] = Guid.NewGuid(),
[nameof(ParticipationEntry.StudentId)] = studentId,
["GroupId"] = groupId,
["Date"] = BsonMapper.Global.Serialize(addedOn),
});
}
using (var context = new LiteDbContext(temp.Path))
{
var group = context.Groups.FindById(groupId);
var membership = context.Memberships.FindById(membershipId);
Assert.NotNull(group);
Assert.NotNull(group.SubjectId);
Assert.Equal("mathematik", context.Subjects.FindById(group.SubjectId.Value)?.Name);
Assert.Equal(1, context.Subjects.Count());
Assert.NotNull(membership);
Assert.Equal(addedOn, membership.AddedOn);
}
using var migrated = new LiteDatabase(temp.Path);
Assert.DoesNotContain("enrollments", migrated.GetCollectionNames());
Assert.Contains("group_memberships", migrated.GetCollectionNames());
var rawGroup = migrated.GetCollection<BsonDocument>("groups").FindById(groupId);
var rawMembership = migrated.GetCollection<BsonDocument>("group_memberships").FindById(membershipId);
var rawExam = migrated.GetCollection<BsonDocument>("exams").FindById(examId);
var rawGrade = migrated.GetCollection<BsonDocument>("grades").FindById(gradeId);
var rawEntry = migrated.GetCollection<BsonDocument>("participation").FindById(entryId);
Assert.False(rawGroup.ContainsKey("Subject"));
Assert.False(rawMembership.ContainsKey("SchoolYear"));
Assert.False(rawMembership.ContainsKey("EnrolledAt"));
Assert.True(rawMembership.ContainsKey(nameof(GroupMembership.AddedOn)));
Assert.False(rawExam.ContainsKey("Subject"));
Assert.False(rawGrade.ContainsKey("SchoolYear"));
Assert.False(rawEntry.ContainsKey("GroupId"));
Assert.False(rawEntry.ContainsKey("Date"));
}
[Fact]
public void RejectsASecondMembershipForTheSameStudentAndGroup()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
var studentId = Guid.NewGuid();
var groupId = Guid.NewGuid();
context.Memberships.Insert(new GroupMembership { StudentId = studentId, GroupId = groupId });
Assert.Throws<LiteException>(() => context.Memberships.Insert(
new GroupMembership { StudentId = studentId, GroupId = groupId }));
}
[Fact]
public void RejectsASecondExamResultForTheSameStudentAndExam()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
var studentId = Guid.NewGuid();
var examId = Guid.NewGuid();
context.ExamResults.Insert(new ExamResult { StudentId = studentId, ExamId = examId });
Assert.Throws<LiteException>(() => context.ExamResults.Insert(
new ExamResult { StudentId = studentId, ExamId = examId }));
}
[Fact]
public void NeueDatenbank_HatDieAktuelleSchemaVersionNachDemErstenOeffnen()
{
using var temp = new TempDatabase();
using var context = new LiteDbContext(temp.Path);
Assert.Equal(6, context.SchemaVersion);
}
[Fact]
public void SchemaVersion_WirdUeberEinenNeustartHinwegBeibehalten()
{
using var temp = new TempDatabase();
using (var first = new LiteDbContext(temp.Path)) { }
using var second = new LiteDbContext(temp.Path);
Assert.Equal(6, second.SchemaVersion);
}
[Fact]
public void MigrateCompetencyCatalogVersions_ErzeugtStandardversionJeFachUndVerknuepftBestehendeBereiche()
{
using var temp = new TempDatabase();
var subjectAId = Guid.NewGuid();
var subjectBId = Guid.NewGuid();
var domainA1Id = Guid.NewGuid();
var domainA2Id = Guid.NewGuid();
var domainBId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
var domains = legacy.GetCollection<BsonDocument>("competency_domains");
domains.Insert(new BsonDocument
{
["_id"] = domainA1Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich A1",
});
domains.Insert(new BsonDocument
{
["_id"] = domainA2Id, [nameof(CompetencyDomain.SubjectId)] = subjectAId,
[nameof(CompetencyDomain.GradeLevel)] = 9, [nameof(CompetencyDomain.Name)] = "Bereich A2",
});
domains.Insert(new BsonDocument
{
["_id"] = domainBId, [nameof(CompetencyDomain.SubjectId)] = subjectBId,
[nameof(CompetencyDomain.GradeLevel)] = 8, [nameof(CompetencyDomain.Name)] = "Bereich B",
});
}
using var context = new LiteDbContext(temp.Path);
var versions = context.CompetencyCatalogVersions.FindAll().ToList();
var versionA = Assert.Single(versions, v => v.SubjectId == subjectAId);
var versionB = Assert.Single(versions, v => v.SubjectId == subjectBId);
Assert.True(versionA.IsDefault);
Assert.True(versionB.IsDefault);
Assert.Equal("Bestehender Kompetenzkatalog", versionA.Name);
var domainsAfter = context.CompetencyDomains.FindAll().ToDictionary(d => d.Id);
Assert.Equal(versionA.Id, domainsAfter[domainA1Id].VersionId);
Assert.Equal(versionA.Id, domainsAfter[domainA2Id].VersionId);
Assert.Equal(versionB.Id, domainsAfter[domainBId].VersionId);
}
[Fact]
public void MigrateCompetencyReferences_LoestCodesGegenKatalogAufUndBehaeltUnbekannteAlsText()
{
using var temp = new TempDatabase();
var unitId = Guid.NewGuid();
var examId = Guid.NewGuid();
var sessionId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var itemId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("competency_domains").Insert(new BsonDocument
{
["_id"] = Guid.NewGuid(),
[nameof(CompetencyDomain.Name)] = "Bereich",
[nameof(CompetencyDomain.Items)] = new BsonArray
{
new BsonDocument
{
["_id"] = itemId,
[nameof(CompetencyItem.Code)] = "K1",
[nameof(CompetencyItem.Description)] = "Erste Kompetenz",
},
},
});
legacy.GetCollection<BsonDocument>("units").Insert(new BsonDocument
{
["_id"] = unitId,
[nameof(Unit.GroupId)] = Guid.NewGuid(),
[nameof(Unit.Competencies)] = new BsonArray { "K1", "ALT" },
});
legacy.GetCollection<BsonDocument>("exams").Insert(new BsonDocument
{
["_id"] = examId,
[nameof(Exam.GroupId)] = Guid.NewGuid(),
[nameof(Exam.Tasks)] = new BsonArray
{
new BsonDocument { ["Nr"] = 1, ["MaxPoints"] = 10.0, ["CompetencyCodes"] = new BsonArray { "K1" } },
},
});
legacy.GetCollection<BsonDocument>("participation_sessions").Insert(new BsonDocument
{
["_id"] = sessionId,
[nameof(ParticipationSession.GroupId)] = Guid.NewGuid(),
["CompetencyCodes"] = new BsonArray { "K1" },
});
legacy.GetCollection<BsonDocument>("participation").Insert(new BsonDocument
{
["_id"] = entryId,
[nameof(ParticipationEntry.SessionId)] = sessionId,
[nameof(ParticipationEntry.StudentId)] = Guid.NewGuid(),
[nameof(ParticipationEntry.CompetencyRatings)] = new BsonArray
{
new BsonDocument { ["Code"] = "K1", ["Value"] = 2 },
new BsonDocument { ["Code"] = "UNBEKANNT", ["Value"] = 1 },
},
});
}
using var context = new LiteDbContext(temp.Path);
var unit = context.Units.FindById(unitId);
Assert.Equal(2, unit!.Competencies.Count);
var known = unit.Competencies.Single(c => c.Code == "K1");
Assert.Equal(itemId, known.ItemId);
Assert.Equal("Erste Kompetenz", known.Description);
var unknown = unit.Competencies.Single(c => c.Code == "ALT");
Assert.Equal(Guid.Empty, unknown.ItemId);
var exam = context.Exams.FindById(examId);
var task = Assert.Single(exam!.Tasks);
var examRef = Assert.Single(task.Competencies);
Assert.Equal(itemId, examRef.ItemId);
var session = context.ParticipationSessions.FindById(sessionId);
var sessionRef = Assert.Single(session!.Competencies);
Assert.Equal(itemId, sessionRef.ItemId);
// Rating mit unbekanntem Code wird verworfen (kein sinnvoll erhaltbarer Zahlenwert ohne
// erkennbare Identität) - nur das auflösbare Rating bleibt übrig.
var entry = context.ParticipationEntries.FindById(entryId);
var rating = Assert.Single(entry!.CompetencyRatings);
Assert.Equal(itemId, rating.ItemId);
Assert.Equal(2, rating.Value);
}
[Fact]
public void MigrateLessonPhases_FasstAltePhaseMethodenUndMaterialienVerlustfreiZusammen()
{
using var temp = new TempDatabase();
var lessonWithDataId = Guid.NewGuid();
var lessonEmptyId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonWithDataId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
["Phase"] = "Einstieg",
["Methods"] = new BsonArray { "Gespräch", "Tafelbild" },
["Materials"] = new BsonArray { "Arbeitsblatt" },
});
// Alte Stunde ganz ohne Phase/Methoden/Materialien — soll leere Phases-Liste bekommen,
// nicht eine leere synthetisierte Zeile.
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonEmptyId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
});
}
using (var context = new LiteDbContext(temp.Path))
{
var withData = context.Lessons.FindById(lessonWithDataId);
Assert.NotNull(withData);
Assert.Single(withData.Phases);
Assert.Equal("Einstieg", withData.Phases[0].Name);
Assert.Equal("Gespräch; Tafelbild", withData.Phases[0].Activity);
Assert.Equal("Arbeitsblatt", withData.Phases[0].Material);
Assert.Equal(0, withData.Phases[0].DurationMinutes);
var empty = context.Lessons.FindById(lessonEmptyId);
Assert.NotNull(empty);
Assert.Empty(empty.Phases);
}
using var migrated = new LiteDatabase(temp.Path);
var raw = migrated.GetCollection<BsonDocument>("lessons").FindById(lessonWithDataId);
Assert.False(raw.ContainsKey("Phase"));
Assert.False(raw.ContainsKey("Methods"));
Assert.False(raw.ContainsKey("Materials"));
}
[Fact]
public void MigrateLessonShorthand_FasstVonUndNachInEinFreitextfeldZusammen()
{
using var temp = new TempDatabase();
var lessonId = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lessonId,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["ShorthandFrom"] = "Tb", ["ShorthandTo"] = "SH" },
new BsonDocument { ["ShorthandFrom"] = "Plenum" }, // nur "Von" gesetzt, kein Pfeil
new BsonDocument { }, // weder Von noch Nach
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var lesson = context.Lessons.FindById(lessonId);
Assert.NotNull(lesson);
Assert.Equal(3, lesson.Phases.Count);
Assert.Equal("Tb->SH", lesson.Phases[0].Shorthand);
Assert.Equal("Plenum", lesson.Phases[1].Shorthand);
Assert.Equal("", lesson.Phases[2].Shorthand);
}
using var migratedShorthand = new LiteDatabase(temp.Path);
var rawLesson = migratedShorthand.GetCollection<BsonDocument>("lessons").FindById(lessonId);
var rawPhases = rawLesson["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandFrom"));
Assert.False(rawPhases[0].AsDocument.ContainsKey("ShorthandTo"));
}
[Fact]
public void MigrateLessonAlternativePaths_ErzeugtKatalogEintraegeUndVerweistDarauf()
{
using var temp = new TempDatabase();
var lesson1Id = Guid.NewGuid();
var lesson2Id = Guid.NewGuid();
using (var legacy = new LiteDatabase(temp.Path))
{
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson1Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" },
new BsonDocument { ["AlternativePath"] = "" }, // Hauptweg, kein Katalogeintrag
new BsonDocument { }, // gar kein Feld, unangetastet
},
});
legacy.GetCollection<BsonDocument>("lessons").Insert(new BsonDocument
{
["_id"] = lesson2Id,
[nameof(Lesson.UnitId)] = Guid.NewGuid(),
[nameof(Lesson.GroupId)] = Guid.NewGuid(),
[nameof(Lesson.Phases)] = new BsonArray
{
new BsonDocument { ["AlternativePath"] = "Kurzversion" }, // gleicher Name -> gleicher Katalogeintrag
},
});
}
using (var context = new LiteDbContext(temp.Path))
{
var catalog = context.AlternativeLessonPaths.FindAll().ToList();
var entry = Assert.Single(catalog);
Assert.Equal("Kurzversion", entry.Name);
var lesson1 = context.Lessons.FindById(lesson1Id)!;
Assert.Equal(entry.Id, lesson1.Phases[0].AlternativePathId);
Assert.Null(lesson1.Phases[1].AlternativePathId);
Assert.Null(lesson1.Phases[2].AlternativePathId);
var lesson2 = context.Lessons.FindById(lesson2Id)!;
Assert.Equal(entry.Id, lesson2.Phases[0].AlternativePathId); // wiederverwendeter Katalogeintrag
}
using var migrated = new LiteDatabase(temp.Path);
var rawLesson1 = migrated.GetCollection<BsonDocument>("lessons").FindById(lesson1Id);
var rawPhases = rawLesson1["Phases"].AsArray;
Assert.False(rawPhases[0].AsDocument.ContainsKey("AlternativePath"));
}
private sealed class TempDatabase : IDisposable
{
private readonly string _directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-tests-{Guid.NewGuid():N}");
public string Path { get; }
public TempDatabase()
{
Directory.CreateDirectory(_directory);
Path = System.IO.Path.Combine(_directory, "test.db");
}
public void Dispose()
{
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}