diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 27a0f20..fe73cf3 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -289,6 +289,9 @@ public interface ICompetencyDomainRepository // gesamten Katalog einer Version (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), // ohne dass der Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste. List GetBySubjectAndVersion(Guid subjectId, Guid versionId); + // Über alle Fächer/Klassenstufen/Versionen hinweg - für Aufrufer, die eine CompetencyItem.Id + // auflösen müssen, ohne vorher das Fach der Zuordnung zu kennen (z.B. MCP add_lesson_competency). + List GetAll(); CompetencyDomain? GetById(Guid id); void Save(CompetencyDomain domain); void Delete(Guid id); diff --git a/LehrerApp.Core/Models/Competency.cs b/LehrerApp.Core/Models/Competency.cs index 7d46e03..9a12dc0 100644 --- a/LehrerApp.Core/Models/Competency.cs +++ b/LehrerApp.Core/Models/Competency.cs @@ -40,3 +40,17 @@ public class CompetencyItem public string Description { get; set; } = ""; public int SortOrder { get; set; } } + +/// Referenz auf eine Kompetenz aus dem Katalog, momentaufnahmeartig gespeichert (analog zu +/// Exam.GradingKey/Exam.Tasks, siehe Datenmodell.md "Bewusst gespeicherte Momentaufnahmen"): +/// Code/Description werden zum Zuordnungszeitpunkt kopiert. Solange ItemId noch im Katalog +/// auflösbar ist, zeigt die UI die live aufgelöste Fassung (ein Rename wirkt sich sofort überall +/// aus); wird das Item gelöscht, bleibt die hier gespeicherte historische Fassung lesbar statt +/// spurlos zu verschwinden. ItemId == Guid.Empty markiert eine nie aufgelöste Referenz (z.B. aus +/// einem plattformübergreifenden Import) - Code/Description sind dann die einzige Information. +public class CompetencyReference +{ + public Guid ItemId { get; set; } + public string Code { get; set; } = ""; + public string Description { get; set; } = ""; +} diff --git a/LehrerApp.Core/Models/Exam.cs b/LehrerApp.Core/Models/Exam.cs index 2303812..d095530 100644 --- a/LehrerApp.Core/Models/Exam.cs +++ b/LehrerApp.Core/Models/Exam.cs @@ -28,7 +28,7 @@ public class ExamTask public string? Title { get; set; } public double MaxPoints { get; set; } public double Weight { get; set; } = 1.0; - public List CompetencyCodes { get; set; } = []; + public List Competencies { get; set; } = []; } public class GradingKeyEntry { diff --git a/LehrerApp.Core/Models/Participation.cs b/LehrerApp.Core/Models/Participation.cs index 45be265..88c0705 100644 --- a/LehrerApp.Core/Models/Participation.cs +++ b/LehrerApp.Core/Models/Participation.cs @@ -7,7 +7,7 @@ public class ParticipationSession public DateOnly Date { get; set; } = DateOnly.FromDateTime(DateTime.Today); public Guid? LessonId { get; set; } public string? Comment { get; set; } - public List CompetencyCodes { get; set; } = []; + public List Competencies { get; set; } = []; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } @@ -101,8 +101,8 @@ public class ParticipationSection public class CompetencyRating { - public string Code { get; set; } = ""; - public int Value { get; set; } + public Guid ItemId { get; set; } + public int Value { get; set; } } public class AspectRating diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 5bf9415..9b4a9d3 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -37,7 +37,7 @@ public class Unit public string Title { get; set; } = ""; public DateOnly? StartDate { get; set; } public DateOnly? EndDate { get; set; } - public List Competencies { get; set; } = []; + public List Competencies { get; set; } = []; public UnitStatus Status { get; set; } = UnitStatus.Planned; public string? Notes { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; @@ -71,11 +71,11 @@ public class Lesson : IHasAttachments /// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird). public bool HomeworkCheckDismissed { get; set; } public string? Reflection { get; set; } - /// Kompetenzcodes () aus dem Katalog, die dieser Stunde als - /// Ganzes zugeordnet sind — analog zu , aber auf Stundenebene statt - /// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner - /// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab. - public List Competencies { get; set; } = []; + /// Kompetenzen aus dem Katalog, die dieser Stunde als Ganzes zugeordnet sind — analog zu + /// , aber auf Stundenebene statt Einheitenebene. Bewusst noch + /// keine Verknüpfung je einzelner (siehe TODO.md 4.5.8, weiterhin + /// offen); diese Liste deckt nur die gröbere Zuordnung ab. + public List Competencies { get; set; } = []; public LessonStatus Status { get; set; } = LessonStatus.Planned; /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie diff --git a/LehrerApp.Core/Services/CompetencyAnalysisService.cs b/LehrerApp.Core/Services/CompetencyAnalysisService.cs index 7dde444..78131f0 100644 --- a/LehrerApp.Core/Services/CompetencyAnalysisService.cs +++ b/LehrerApp.Core/Services/CompetencyAnalysisService.cs @@ -32,31 +32,54 @@ public sealed class CompetencyAnalysisService IReadOnlyList results) { var builders = new List(); - var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + var byId = new Dictionary(); + // Referenzen, die nie einem Katalogeintrag zugeordnet werden konnten (ItemId == Guid.Empty, + // z.B. aus einem plattformübergreifenden Import) haben keine gemeinsame Id, über die + // mehrfache Zuordnungen derselben Kompetenz zusammengeführt werden könnten - dafür wird + // ersatzweise der Code-Text als Merge-Schlüssel verwendet. + var byUnresolvedCode = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var domain in catalog.OrderBy(x => x.SortOrder)) foreach (var item in domain.Items.OrderBy(x => x.SortOrder)) { var builder = new ItemBuilder(domain.Name, domain.Code, item.Code, item.Description); builders.Add(builder); - if (!string.IsNullOrWhiteSpace(item.Code)) byCode.TryAdd(item.Code.Trim(), builder); + byId.TryAdd(item.Id, builder); } - ItemBuilder GetOrAdd(string rawCode) + // Solange die Referenz noch im aktuellen Katalog auflösbar ist, zählt die LIVE-Fassung + // (ein Rename wirkt sich damit sofort aus). Ist sie es nicht mehr (gelöscht, andere + // Bildungsplan-Version, oder nie aufgelöster plattformübergreifender Import), wird ein + // Platzhalter aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme synthetisiert - + // dadurch bleibt (anders als bei der bisherigen reinen Code-Textkonserve) sogar die echte + // historische Beschreibung erhalten. + ItemBuilder GetOrAdd(CompetencyReference reference) { - var code = rawCode.Trim(); - if (byCode.TryGetValue(code, out var existing)) return existing; - var added = new ItemBuilder("Nicht im Katalog", "", code, - "Dieser Kompetenzcode wird noch verwendet, ist aber nicht im aktuellen Katalog enthalten."); - byCode[code] = added; + var description = string.IsNullOrWhiteSpace(reference.Description) + ? "Diese Kompetenz wird noch verwendet, ist aber nicht (mehr) im aktuellen Katalog enthalten." + : reference.Description; + + if (reference.ItemId == Guid.Empty) + { + var codeKey = reference.Code.Trim(); + if (byUnresolvedCode.TryGetValue(codeKey, out var existingUnresolved)) return existingUnresolved; + var addedUnresolved = new ItemBuilder("Nicht im Katalog", "", reference.Code, description); + byUnresolvedCode[codeKey] = addedUnresolved; + builders.Add(addedUnresolved); + return addedUnresolved; + } + + if (byId.TryGetValue(reference.ItemId, out var existing)) return existing; + var added = new ItemBuilder("Nicht im Katalog", "", reference.Code, description); + byId[reference.ItemId] = added; builders.Add(added); return added; } foreach (var unit in units) - foreach (var code in NormalizeCodes(unit.Competencies)) + foreach (var reference in NormalizeReferences(unit.Competencies)) { - var builder = GetOrAdd(code); + var builder = GetOrAdd(reference); if (unit.Status == UnitStatus.Planned) builder.PlannedUnits++; else builder.TaughtUnits++; } @@ -64,10 +87,10 @@ public sealed class CompetencyAnalysisService var resultsByExam = results.GroupBy(x => x.ExamId).ToDictionary(x => x.Key, x => x.ToList()); foreach (var exam in exams) { - var examCodes = NormalizeCodes(exam.Tasks.SelectMany(x => x.CompetencyCodes)); - foreach (var code in examCodes) + var examReferences = NormalizeReferences(exam.Tasks.SelectMany(x => x.Competencies)); + foreach (var reference in examReferences) { - var builder = GetOrAdd(code); + var builder = GetOrAdd(reference); if (exam.Status == ExamStatus.Planned) builder.PlannedExams++; else builder.AssessedExams++; } @@ -81,17 +104,16 @@ public sealed class CompetencyAnalysisService var task = exam.Tasks[taskIndex]; if (task.MaxPoints <= 0) continue; var achieved = Math.Clamp(result.Points[taskIndex], 0, task.MaxPoints); - foreach (var code in NormalizeCodes(task.CompetencyCodes)) - GetOrAdd(code).AddScore(result.StudentId, achieved, task.MaxPoints); + foreach (var reference in NormalizeReferences(task.Competencies)) + GetOrAdd(reference).AddScore(result.StudentId, achieved, task.MaxPoints); } } return builders.Select(x => x.Build()).ToList(); } - private static bool HasCode(string? value) => !string.IsNullOrWhiteSpace(value); - private static IEnumerable NormalizeCodes(IEnumerable codes) => - codes.Where(HasCode).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase); + private static IEnumerable NormalizeReferences(IEnumerable references) => + references.DistinctBy(x => x.ItemId); private sealed class ItemBuilder( string domainName, string domainCode, string code, string description) diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index c37d2b3..9dd2b12 100644 --- a/LehrerApp.Data.Tests/LiteDbContextTests.cs +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -121,7 +121,7 @@ public sealed class LiteDbContextTests using var temp = new TempDatabase(); using var context = new LiteDbContext(temp.Path); - Assert.Equal(5, context.SchemaVersion); + Assert.Equal(6, context.SchemaVersion); } [Fact] @@ -132,7 +132,7 @@ public sealed class LiteDbContextTests using var second = new LiteDbContext(temp.Path); - Assert.Equal(5, second.SchemaVersion); + Assert.Equal(6, second.SchemaVersion); } [Fact] @@ -180,6 +180,93 @@ public sealed class LiteDbContextTests 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("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("units").Insert(new BsonDocument + { + ["_id"] = unitId, + [nameof(Unit.GroupId)] = Guid.NewGuid(), + [nameof(Unit.Competencies)] = new BsonArray { "K1", "ALT" }, + }); + legacy.GetCollection("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("participation_sessions").Insert(new BsonDocument + { + ["_id"] = sessionId, + [nameof(ParticipationSession.GroupId)] = Guid.NewGuid(), + ["CompetencyCodes"] = new BsonArray { "K1" }, + }); + legacy.GetCollection("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() { diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 34699c1..ccd2be5 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -15,7 +15,7 @@ public class LiteDbContext : IDisposable /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter /// ergänzt, statt bei jedem Start erneut /// (idempotent, aber unnötig) über alle Daten zu laufen. - private const int CurrentSchemaVersion = 5; + private const int CurrentSchemaVersion = 6; private readonly LiteDatabase _db; @@ -268,6 +268,11 @@ public class LiteDbContext : IDisposable MigrateCompetencyCatalogVersions(); version = 5; } + if (version < 6) + { + MigrateCompetencyReferences(); + version = 6; + } WriteSchemaVersion(version); } @@ -516,6 +521,114 @@ public class LiteDbContext : IDisposable } } + /// TODO 16.5: stellt Unit/Lesson/ExamTask/ParticipationSession-Kompetenzzuordnungen von + /// Freitext-Codes auf stabile CompetencyItem.Id-Referenzen um (siehe CompetencyReference). + /// Jeder alte Code wird gegen den Katalog aufgelöst (erster Treffer über alle Fächer/Versionen + /// hinweg, case-insensitiv) und als Momentaufnahme {ItemId, Code, Description} gespeichert; + /// ohne Treffer bleibt der Text als ItemId=Guid.Empty erhalten statt verloren zu gehen. Bei + /// ExamTask/ParticipationSession wird das Feld zugleich von "CompetencyCodes" auf "Competencies" + /// umbenannt (das alte typisierte Feld existiert nicht mehr). ParticipationEntry.CompetencyRatings + /// ohne auflösbaren Code werden verworfen - ein Zahlenwert ohne erkennbare Identität ist nicht + /// sinnvoll erhaltbar. + private void MigrateCompetencyReferences() + { + var byCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var domain in CompetencyDomains.FindAll()) + foreach (var item in domain.Items) + { + if (string.IsNullOrWhiteSpace(item.Code)) continue; + byCode.TryAdd(item.Code.Trim(), item); + } + + BsonArray ConvertCodesToReferences(BsonValue oldValue) + { + var result = new BsonArray(); + if (!oldValue.IsArray) return result; + foreach (var entry in oldValue.AsArray) + { + if (!entry.IsString) continue; + var code = entry.AsString.Trim(); + if (code.Length == 0) continue; + result.Add(byCode.TryGetValue(code, out var item) + ? new BsonDocument + { + [nameof(CompetencyReference.ItemId)] = item.Id, + [nameof(CompetencyReference.Code)] = item.Code, + [nameof(CompetencyReference.Description)] = item.Description, + } + : new BsonDocument + { + [nameof(CompetencyReference.ItemId)] = Guid.Empty, + [nameof(CompetencyReference.Code)] = code, + [nameof(CompetencyReference.Description)] = "", + }); + } + return result; + } + + var units = _db.GetCollection("units"); + foreach (var doc in units.FindAll().ToList()) + { + if (!doc.TryGetValue(nameof(Unit.Competencies), out var old)) continue; + doc[nameof(Unit.Competencies)] = ConvertCodesToReferences(old); + units.Update(doc); + } + + var lessonDocs = _db.GetCollection("lessons"); + foreach (var doc in lessonDocs.FindAll().ToList()) + { + if (!doc.TryGetValue(nameof(Lesson.Competencies), out var old)) continue; + doc[nameof(Lesson.Competencies)] = ConvertCodesToReferences(old); + lessonDocs.Update(doc); + } + + var exams = _db.GetCollection("exams"); + foreach (var doc in exams.FindAll().ToList()) + { + if (!doc.TryGetValue(nameof(Exam.Tasks), out var tasksValue) || !tasksValue.IsArray) continue; + var changed = false; + foreach (var taskValue in tasksValue.AsArray) + { + if (taskValue is not BsonDocument task) continue; + if (!task.TryGetValue("CompetencyCodes", out var old)) continue; + task.Remove("CompetencyCodes"); + task[nameof(ExamTask.Competencies)] = ConvertCodesToReferences(old); + changed = true; + } + if (changed) exams.Update(doc); + } + + var sessions = _db.GetCollection("participation_sessions"); + foreach (var doc in sessions.FindAll().ToList()) + { + if (!doc.TryGetValue("CompetencyCodes", out var old)) continue; + doc.Remove("CompetencyCodes"); + doc[nameof(ParticipationSession.Competencies)] = ConvertCodesToReferences(old); + sessions.Update(doc); + } + + var entries = _db.GetCollection("participation"); + foreach (var doc in entries.FindAll().ToList()) + { + if (!doc.TryGetValue(nameof(ParticipationEntry.CompetencyRatings), out var ratingsValue) || !ratingsValue.IsArray) continue; + var newRatings = new BsonArray(); + foreach (var ratingValue in ratingsValue.AsArray) + { + if (ratingValue is not BsonDocument rating) continue; + if (!rating.TryGetValue("Code", out var codeVal) || !codeVal.IsString) continue; + if (!byCode.TryGetValue(codeVal.AsString.Trim(), out var item)) continue; + var value = rating.TryGetValue(nameof(CompetencyRating.Value), out var v) && v.IsNumber ? v.AsInt32 : 0; + newRatings.Add(new BsonDocument + { + [nameof(CompetencyRating.ItemId)] = item.Id, + [nameof(CompetencyRating.Value)] = value, + }); + } + doc[nameof(ParticipationEntry.CompetencyRatings)] = newRatings; + entries.Update(doc); + } + } + private void EnsureIndexes() { Students.EnsureIndex(x => x.LastName); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 04bf926..3bd9cdb 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -927,6 +927,7 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep .Find(d => d.SubjectId == subjectId && d.VersionId == versionId) .OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder) .ToList(); + public List GetAll() => db.CompetencyDomains.FindAll().ToList(); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public void Save(CompetencyDomain d) { diff --git a/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs index 23bed65..ef53e2d 100644 --- a/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CompetencyOverviewViewModelTests.cs @@ -13,16 +13,18 @@ public sealed class CompetencyOverviewViewModelTests var subjectId = Guid.NewGuid(); var group = new LearningGroup { SubjectId = subjectId, GradeLevel = 8 }; var student = new Student { FirstName = "Ada", LastName = "Lovelace" }; + var k1 = new CompetencyItem { Code = "K1", Description = "Kompetenz" }; var units = new FakeUnits(); units.Add(new Unit { - GroupId = group.Id, Status = UnitStatus.Completed, Competencies = ["K1"], + GroupId = group.Id, Status = UnitStatus.Completed, + Competencies = [new CompetencyReference { ItemId = k1.Id, Code = k1.Code, Description = k1.Description }], }); var exam = new Exam { GroupId = group.Id, Status = ExamStatus.Graded, - Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }], + Tasks = [new ExamTask { MaxPoints = 10, Competencies = [new CompetencyReference { ItemId = k1.Id, Code = k1.Code, Description = k1.Description }] }], }; var results = new FakeResults(); results.Add(new ExamResult { ExamId = exam.Id, StudentId = student.Id, Points = [5] }); @@ -35,7 +37,7 @@ public sealed class CompetencyOverviewViewModelTests GradeLevel = 8, VersionId = version.Id, Name = "Bereich", - Items = [new CompetencyItem { Code = "K1", Description = "Kompetenz" }], + Items = [k1], }); var vm = new CompetencyOverviewTabViewModel(units, new FakeExams([exam]), results, catalog, versions, new FakeStudents([student]), new CompetencyAnalysisService()); diff --git a/LehrerApp.Desktop.Tests/CompetencyTagGroupBuilderTests.cs b/LehrerApp.Desktop.Tests/CompetencyTagGroupBuilderTests.cs new file mode 100644 index 0000000..eed8828 --- /dev/null +++ b/LehrerApp.Desktop.Tests/CompetencyTagGroupBuilderTests.cs @@ -0,0 +1,64 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class CompetencyTagGroupBuilderTests +{ + [Fact] + public void Build_MarkiertAusgewaehlteItemsAlsSelected() + { + var item = new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }; + var catalog = new List { new() { Name = "Bereich", Items = [item] } }; + var selected = new List { new() { ItemId = item.Id, Code = item.Code, Description = item.Description } }; + + var groups = CompetencyTagGroupBuilder.Build(catalog, selected, _ => { }); + + var group = Assert.Single(groups); + var tag = Assert.Single(group.Items); + Assert.True(tag.IsSelected); + Assert.Equal(item.Id, tag.ItemId); + } + + [Fact] + public void Build_ReferenzOhneKatalogTreffer_LandetInEigenerGruppeMitMomentaufnahme() + { + var catalog = new List + { + new() { Name = "Bereich", Items = [new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }] }, + }; + var orphaned = new CompetencyReference + { + ItemId = Guid.NewGuid(), Code = "ALT1", Description = "Alte, gelöschte Kompetenz", + }; + var selected = new List { orphaned }; + + var groups = CompetencyTagGroupBuilder.Build(catalog, selected, _ => { }); + + Assert.Equal(2, groups.Count); + var fallbackGroup = groups.Single(g => g.Name == "Nicht mehr im Katalog"); + var tag = Assert.Single(fallbackGroup.Items); + Assert.True(tag.IsSelected); + Assert.Equal("ALT1", tag.Code); + Assert.Equal("Alte, gelöschte Kompetenz", tag.Description); + // Der reguläre Katalog-Bereich bleibt unberührt und zeigt sein einziges Item als abwählbar. + var catalogGroup = groups.Single(g => g.Name == "Bereich"); + Assert.False(Assert.Single(catalogGroup.Items).IsSelected); + } + + [Fact] + public void Build_ToggleAufTag_RuftOnChangedMitDerTagInstanzAuf() + { + var item = new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }; + var catalog = new List { new() { Name = "Bereich", Items = [item] } }; + CompetencyTag? changed = null; + + var groups = CompetencyTagGroupBuilder.Build(catalog, [], tag => changed = tag); + var tag = Assert.Single(Assert.Single(groups).Items); + tag.IsSelected = true; + + Assert.Same(tag, changed); + Assert.Equal(item.Id, changed!.ItemId); + } +} diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 7b39501..9530874 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -397,6 +397,7 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository public List GetBySubjectAndVersion(Guid subjectId, Guid versionId) => _all.Where(d => d.SubjectId == subjectId && d.VersionId == versionId) .OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList(); + public List GetAll() => _all.ToList(); public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id); public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); } public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index 8936af0..dbd19fc 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -258,9 +258,11 @@ public sealed class McpToolsTests private static LessonPlanTools BuildLessonPlanTools( FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null, - FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) => + FakeAttachmentStorage? attachments = null, FakeCompetencyDomains? competencyDomains = null, + FakeMcpConfirmation? confirmation = null) => new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]), - attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation()); + attachments ?? new FakeAttachmentStorage(), competencyDomains ?? new FakeCompetencyDomains(), + confirmation ?? new FakeMcpConfirmation()); [Fact] public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum() @@ -671,31 +673,58 @@ public sealed class McpToolsTests // ── LessonPlanTools — Kompetenzzuordnung ──────────────────────────────────────────────────── + private static FakeCompetencyDomains CatalogWithItem(out CompetencyItem item) + { + item = new CompetencyItem { Code = "PH.9.1", Description = "Brechungsgesetz anwenden" }; + var domains = new FakeCompetencyDomains(); + domains.Add(new CompetencyDomain { Name = "Optik", Items = [item] }); + return domains; + } + [Fact] - public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu() + public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetKompetenzZu() { var lesson = new Lesson { Topic = "Brechung" }; var lessons = new FakeLessons(); lessons.Add(lesson); - var tool = BuildLessonPlanTools(lessons: lessons); + var domains = CatalogWithItem(out var item); + var tool = BuildLessonPlanTools(lessons: lessons, competencyDomains: domains); - var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1"); + var result = await tool.AddLessonCompetency(lesson.Id, item.Id); Assert.True(result.Applied); - Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies); + var stored = Assert.Single(lessons.GetById(lesson.Id)!.Competencies); + Assert.Equal(item.Id, stored.ItemId); + Assert.Equal("PH.9.1", stored.Code); + } + + [Fact] + public async Task AddLessonCompetency_UnbekannteId_LiefertFehlerOhneNachfrage() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.AddLessonCompetency(lesson.Id, Guid.NewGuid()); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); } [Fact] public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach() { var lesson = new Lesson { Topic = "Brechung" }; - lesson.Competencies.Add("PH.9.1"); + var domains = CatalogWithItem(out var item); + lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }); var lessons = new FakeLessons(); lessons.Add(lesson); var confirmation = new FakeMcpConfirmation(); - var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + var tool = BuildLessonPlanTools(lessons: lessons, competencyDomains: domains, confirmation: confirmation); - var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1"); + var result = await tool.AddLessonCompetency(lesson.Id, item.Id); Assert.True(result.Applied); Assert.Equal(0, confirmation.CallCount); @@ -703,15 +732,16 @@ public sealed class McpToolsTests } [Fact] - public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode() + public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntKompetenz() { var lesson = new Lesson { Topic = "Brechung" }; - lesson.Competencies.Add("PH.9.1"); + var item = new CompetencyItem { Code = "PH.9.1", Description = "Brechungsgesetz anwenden" }; + lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }); var lessons = new FakeLessons(); lessons.Add(lesson); var tool = BuildLessonPlanTools(lessons: lessons); - var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1"); + var result = await tool.RemoveLessonCompetency(lesson.Id, item.Id); Assert.True(result.Applied); Assert.Empty(lessons.GetById(lesson.Id)!.Competencies); @@ -726,7 +756,7 @@ public sealed class McpToolsTests var confirmation = new FakeMcpConfirmation(); var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); - var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1"); + var result = await tool.RemoveLessonCompetency(lesson.Id, Guid.NewGuid()); Assert.False(result.Applied); Assert.Equal(0, confirmation.CallCount); diff --git a/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs b/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs index ea3f6a7..21f58c1 100644 --- a/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs +++ b/LehrerApp.Desktop.Tests/ParticipationSessionEditingTests.cs @@ -15,7 +15,7 @@ public sealed class ParticipationSessionEditingTests Date = new DateOnly(2026, 8, 19), Comment = "Sitzplan", LessonId = Guid.NewGuid(), - CompetencyCodes = ["K1"], + Competencies = [new CompetencyReference { ItemId = Guid.NewGuid(), Code = "K1", Description = "Kompetenz" }], }; var vm = new AddSessionDialogViewModel(session) { @@ -29,7 +29,7 @@ public sealed class ParticipationSessionEditingTests Assert.Equal(new DateOnly(2026, 8, 20), session.Date); Assert.Equal("Elektrische Stromkreise", session.Comment); Assert.NotNull(session.LessonId); - Assert.Equal(["K1"], session.CompetencyCodes); + Assert.Equal(["K1"], session.Competencies.Select(c => c.Code)); } [Fact] diff --git a/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs b/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs index 542ca31..a632f60 100644 --- a/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs +++ b/LehrerApp.Desktop.Tests/PlanningExchangeServiceTests.cs @@ -23,7 +23,11 @@ public sealed class PlanningExchangeServiceTests Title = "Optik", StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2026, 10, 1), - Competencies = ["UF1", "E4"], + Competencies = + [ + new CompetencyReference { ItemId = Guid.NewGuid(), Code = "UF1", Description = "" }, + new CompetencyReference { ItemId = Guid.NewGuid(), Code = "E4", Description = "" }, + ], Status = UnitStatus.Active, Notes = "Experimente", }; @@ -61,7 +65,11 @@ public sealed class PlanningExchangeServiceTests Assert.Equal(targetGroupId, result.Unit.GroupId); Assert.Equal("Optik", result.Unit.Title); Assert.Equal(UnitStatus.Active, result.Unit.Status); - Assert.Equal(["UF1", "E4"], result.Unit.Competencies); + // Codes werden exportiert/importiert (Momentaufnahme-Text), aber bewusst NICHT gegen den + // Katalog der Zielinstallation aufgelöst - jede importierte Referenz bleibt ItemId=Guid.Empty + // (siehe PlanningExchangeService-Klassenkommentar: nie interne IDs übertragen). + Assert.Equal(["UF1", "E4"], result.Unit.Competencies.Select(c => c.Code)); + Assert.All(result.Unit.Competencies, c => Assert.Equal(Guid.Empty, c.ItemId)); var importedLesson = Assert.Single(result.Lessons); Assert.NotEqual(sourceLesson.Id, importedLesson.Id); Assert.Equal(result.Unit.Id, importedLesson.UnitId); diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 0d56b93..de7fb15 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -205,7 +205,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, Title = unit.Title, StartDate = unit.StartDate, EndDate = unit.EndDate, - Competencies = unit.Competencies, + Competencies = unit.Competencies.Select(c => c.Code).ToList(), Notes = unit.Notes, SubjectName = subject?.Name ?? "", GradeLevel = gradeLevel, diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index 23eb61b..c7e64d0 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -34,9 +34,14 @@ public record LessonPhaseDto( public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes); +/// Eine über MCP zugeordnete Kompetenz - Id + zum Zuordnungszeitpunkt gespeicherte +/// Momentaufnahme aus Code/Description (siehe CompetencyReference), damit ein KI-Client sie ohne +/// Zusatzabfrage lesen kann, auch wenn das Katalog-Item inzwischen gelöscht wurde. +public record CompetencyReferenceDto(Guid ItemId, string Code, string Description); + public record LessonDto( Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, - string? Homework, string? PlanningIdeas, LessonStatus Status, List Competencies, + string? Homework, string? PlanningIdeas, LessonStatus Status, List Competencies, List Phases, List Attachments); /// Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten @@ -47,7 +52,7 @@ public record AttachmentContentDto(string FileName, long SizeBytes, string Base6 public record UnitDto( Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate, - UnitStatus Status, List Competencies); + UnitStatus Status, List Competencies); public record LessonPlanResultDto(List Units, List Lessons); diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index cb10c1b..2b46708 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -25,7 +25,8 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools; /// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht. public class LessonPlanTools( IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, - IAttachmentStorage attachments, IMcpConfirmationService confirmation) + IAttachmentStorage attachments, ICompetencyDomainRepository competencyDomains, + IMcpConfirmationService confirmation) { /// Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um /// Faktor 1,33 auf). Kleiner als (App-weites @@ -42,7 +43,7 @@ public class LessonPlanTools( [Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to) { var unitDtos = units.GetByGroup(groupId) - .Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies)) + .Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, ToDto(u.Competencies))) .ToList(); var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to) .Select(ToDto) @@ -311,40 +312,40 @@ public class LessonPlanTools( // Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) - // ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste. - [Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")] + [Description("Ordnet einer Einzelstunde eine Kompetenz aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")] public async Task AddLessonCompetency( [Description("ID der Einzelstunde.")] Guid lessonId, - [Description("Kompetenzcode, z.B. \"M.5.1\".")] string code, + [Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); - code = code.Trim(); - if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode."); - if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase)) - return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet."); + var item = FindCompetencyItem(itemId); + if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID."); + if (lesson.Competencies.Any(c => c.ItemId == itemId)) + return new WriteResultDto(true, lesson.Id, "Kompetenz ist bereits zugeordnet."); - var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; + var message = $"Kompetenz „{item.Code}“ zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); - lesson.Competencies.Add(code); + lesson.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }); lessons.Save(lesson); return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet."); } - [Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")] + [Description("Entfernt eine Kompetenz von einer Einzelstunde. Muss der Nutzer erst bestätigen.")] public async Task RemoveLessonCompetency( [Description("ID der Einzelstunde.")] Guid lessonId, - [Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code, + [Description("ID der Einzelkompetenz, wie in get_lesson_plans hinterlegt.")] Guid itemId, CancellationToken ct = default) { var lesson = lessons.GetById(lessonId); if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); - var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase)); - if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet."); + var existing = lesson.Competencies.FirstOrDefault(c => c.ItemId == itemId); + if (existing is null) return new WriteResultDto(false, null, "Diese Kompetenz ist dieser Stunde nicht zugeordnet."); - var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?"; + var message = $"Kompetenz „{existing.Code}“ von „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?"; if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct)) return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); @@ -353,6 +354,9 @@ public class LessonPlanTools( return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt."); } + private CompetencyItem? FindCompetencyItem(Guid itemId) => + competencyDomains.GetAll().SelectMany(d => d.Items).FirstOrDefault(i => i.Id == itemId); + // ── Einzelstunden (Lesson) — Verschieben & Löschen ─────────────────────────────────────── [Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")] @@ -414,7 +418,10 @@ public class LessonPlanTools( } private static LessonDto ToDto(Lesson l) => new( - l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, l.Competencies, + l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.PlanningIdeas, l.Status, ToDto(l.Competencies), l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand, p.MaterialPrompt)).ToList(), l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList()); + + private static List ToDto(List competencies) => + competencies.Select(c => new CompetencyReferenceDto(c.ItemId, c.Code, c.Description)).ToList(); } diff --git a/LehrerApp.Desktop/Services/PlanningExchangeService.cs b/LehrerApp.Desktop/Services/PlanningExchangeService.cs index 05db7c3..64343d7 100644 --- a/LehrerApp.Desktop/Services/PlanningExchangeService.cs +++ b/LehrerApp.Desktop/Services/PlanningExchangeService.cs @@ -62,9 +62,16 @@ public sealed class PlanningExchangeService(IUnitRepository units, ILessonReposi Title = document.Unit.Title.Trim(), StartDate = document.Unit.StartDate, EndDate = document.Unit.EndDate, + // Das Austauschformat überträgt bewusst nie interne DB-IDs ("jeder Import legt neue + // Objekte an", siehe Klassenkommentar) - jeder importierte Code wird deshalb als NICHT + // aufgelöste Referenz gespeichert (kein Matching gegen den Katalog der Zielinstallation, + // das wäre wieder dasselbe fragile Code-Matching, das TODO 16.5 vermeiden soll, nur + // installationsübergreifend). Die Tag-UI zeigt sie ehrlich als "Nicht mehr im Katalog"; + // der Nutzer kann sie bei Bedarf manuell dem echten Katalogeintrag neu zuordnen. Competencies = document.Unit.Competencies .Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(c => new CompetencyReference { ItemId = Guid.Empty, Code = c }).ToList(), Status = document.Unit.Status, Notes = Clean(document.Unit.Notes), }; @@ -94,7 +101,7 @@ public sealed class PlanningExchangeService(IUnitRepository units, ILessonReposi Title = unit.Title, StartDate = unit.StartDate, EndDate = unit.EndDate, - Competencies = [.. unit.Competencies], + Competencies = [.. unit.Competencies.Select(c => c.Code)], Status = unit.Status, Notes = unit.Notes, Lessons = [.. unitLessons.Select(ToPayload)], diff --git a/LehrerApp.Desktop/ViewModels/Groups/CompetencyTagging.cs b/LehrerApp.Desktop/ViewModels/Groups/CompetencyTagging.cs new file mode 100644 index 0000000..14da44a --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Groups/CompetencyTagging.cs @@ -0,0 +1,91 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.ViewModels.Groups; + +// ── Kompetenz-Tags ────────────────────────────────────────────────────────────── +// +// Gemeinsame Toggle-UI für die drei Stellen, an denen Kompetenzen aus dem Katalog einem anderen +// Objekt zugeordnet werden (Unterrichtseinheit, Klausuraufgabe, Mitarbeit-Sitzung) - vor der +// Umstellung auf CompetencyItem.Id (TODO 16.5) war die Bau-Logik dreifach fast identisch +// dupliziert, jetzt an einer Stelle. + +public class CompetencyTagGroup(string name, string code) +{ + public string Name { get; } = name; + public string Code { get; } = code; + public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})"; + public List Items { get; } = []; +} + +public partial class CompetencyTag : ObservableObject +{ + public Guid ItemId { get; } + public string Code { get; } + public string Description { get; } + public string Display { get; } + + [ObservableProperty] private bool _isSelected; + + /// Reicht die Tag-Instanz selbst durch, statt nur Id/Zustand - der Aufrufer liest + /// Code/Description direkt vom Tag, ohne eine eigene Lookup-Tabelle führen zu müssen. + public Action? OnChanged { get; set; } + + public CompetencyTag(Guid itemId, string code, string description, bool isSelected) + { + ItemId = itemId; + Code = code; + Description = description; + Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}"; + _isSelected = isSelected; + } + + partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(this); +} + +public static class CompetencyTagGroupBuilder +{ + /// Baut Tag-Gruppen aus dem aktuellen Katalog plus eine synthetische Gruppe "Nicht mehr + /// im Katalog" für bereits zugeordnete Referenzen, deren ItemId im übergebenen Katalog nicht + /// auflösbar ist (gelöscht, andere Bildungsplan-Version, oder nie aufgelöster Import) - zeigt + /// deren gespeicherte Momentaufnahme, damit eine Zuordnung nie stillschweigend verschwindet + /// (Kernanliegen von TODO 16.5). Tags der Katalog-Gruppen sind auf/abwählbar; Tags der + /// "Nicht mehr im Katalog"-Gruppe sind immer bereits ausgewählt (es gibt keinen Katalogeintrag, + /// dem man sie stattdessen zuordnen könnte) und lassen sich nur abwählen/entfernen. + public static List Build( + IReadOnlyList catalog, List selected, + Action onChanged) + { + var groups = new List(); + var matchedIds = new HashSet(); + + foreach (var domain in catalog.OrderBy(d => d.SortOrder)) + { + var group = new CompetencyTagGroup(domain.Name, domain.Code); + foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) + { + var isSelected = selected.Any(s => s.ItemId == item.Id); + if (isSelected) matchedIds.Add(item.Id); + group.Items.Add(new CompetencyTag(item.Id, item.Code, item.Description, isSelected) + { + OnChanged = onChanged, + }); + } + if (group.Items.Count > 0) groups.Add(group); + } + + var orphaned = selected.Where(s => !matchedIds.Contains(s.ItemId)).ToList(); + if (orphaned.Count > 0) + { + var group = new CompetencyTagGroup("Nicht mehr im Katalog", ""); + foreach (var reference in orphaned) + group.Items.Add(new CompetencyTag(reference.ItemId, reference.Code, reference.Description, isSelected: true) + { + OnChanged = onChanged, + }); + groups.Add(group); + } + + return groups; + } +} diff --git a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs index 444c68c..c2702d2 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ExamViewModels.cs @@ -98,7 +98,7 @@ public partial class ExamDialogViewModel : ObservableObject // gerade eine andere Niveau-Variante (E/G/Förder) derselben Klausur anzulegen. SelectedNiveauName = isDuplicate ? "–" : NiveauDisplay.ToName(source.Niveau); foreach (var t in source.Tasks.OrderBy(t => t.Nr)) - AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.CompetencyCodes]); + AddTaskInternal(t.Title, t.MaxPoints, t.Weight, [.. t.Competencies]); UseWeighting = Tasks.Any(t => Math.Abs(t.Weight - 1.0) > 0.0001); foreach (var e in source.GradingKey.OrderByDescending(e => e.MinPercent)) AddGradingKeyRowInternal(e.Grade, e.MinPercent); @@ -148,10 +148,10 @@ public partial class ExamDialogViewModel : ObservableObject RecomputeTotals(); } - private void AddTaskInternal(string? title, double maxPoints, double weight, List competencyCodes) + private void AddTaskInternal(string? title, double maxPoints, double weight, List competencies) { - var item = new ExamTaskEditItem(title, maxPoints, weight, competencyCodes, - BuildCompetencyTagGroups(competencyCodes)) + var item = new ExamTaskEditItem(title, maxPoints, weight, competencies, + BuildCompetencyTagGroups(competencies)) { OnChanged = RecomputeTotals, OnRemove = RemoveTask, @@ -162,19 +162,13 @@ public partial class ExamDialogViewModel : ObservableObject RenumberTasks(); } - private List BuildCompetencyTagGroups(List selectedCodes) + private List BuildCompetencyTagGroups(List selected) { - var groups = new List(); - if (!_subjectId.HasValue) return groups; - var selected = selectedCodes.ToHashSet(); - foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId)) - { - var group = new CompetencyTagGroup(domain.Name, domain.Code); - foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) - group.Items.Add(new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code))); - if (group.Items.Count > 0) groups.Add(group); - } - return groups; + if (!_subjectId.HasValue) return []; + var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId); + // onChanged wird hier bewusst nicht gesetzt (bleibt null) - ExamTaskEditItem verdrahtet + // tag.OnChanged selbst auf seinen eigenen OnCompetencyToggled-Handler, siehe Konstruktor. + return CompetencyTagGroupBuilder.Build(catalog, selected, _ => { }); } private void RemoveTask(ExamTaskEditItem item) @@ -368,12 +362,12 @@ public partial class ExamTaskEditItem : ObservableObject [ObservableProperty] private bool _isCompetencyPanelOpen; [ObservableProperty] private bool _showWeight; - public List CompetencyCodes { get; } + public List Competencies { get; } public ObservableCollection CompetencyTagGroups { get; } = []; - public string CompetencySummary => CompetencyCodes.Count == 0 + public string CompetencySummary => Competencies.Count == 0 ? "Keine Kompetenzen" - : $"{CompetencyCodes.Count} Kompetenz(en)"; + : $"{Competencies.Count} Kompetenz(en)"; public Action? OnChanged { get; set; } public Action? OnRemove { get; set; } @@ -381,12 +375,12 @@ public partial class ExamTaskEditItem : ObservableObject public Action? OnMoveDown { get; set; } public ExamTaskEditItem(string? title, double maxPoints, double weight, - List competencyCodes, List tagGroups) + List competencies, List tagGroups) { _title = title ?? ""; _maxPoints = maxPoints; _weight = weight; - CompetencyCodes = competencyCodes; + Competencies = competencies; foreach (var g in tagGroups) { foreach (var tag in g.Items) tag.OnChanged = OnCompetencyToggled; @@ -394,10 +388,17 @@ public partial class ExamTaskEditItem : ObservableObject } } - private void OnCompetencyToggled(string code, bool selected) + private void OnCompetencyToggled(CompetencyTag tag) { - if (selected) { if (!CompetencyCodes.Contains(code)) CompetencyCodes.Add(code); } - else CompetencyCodes.Remove(code); + if (tag.IsSelected) + { + if (!Competencies.Any(r => r.ItemId == tag.ItemId)) + Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description }); + } + else + { + Competencies.RemoveAll(r => r.ItemId == tag.ItemId); + } OnPropertyChanged(nameof(CompetencySummary)); OnChanged?.Invoke(); } @@ -415,6 +416,6 @@ public partial class ExamTaskEditItem : ObservableObject Title = string.IsNullOrWhiteSpace(Title) ? null : Title.Trim(), MaxPoints = MaxPoints, Weight = Weight, - CompetencyCodes = CompetencyCodes, + Competencies = Competencies, }; } diff --git a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs index b7e04e0..afbc4f1 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/ParticipationViewModels.cs @@ -42,7 +42,7 @@ public partial class ParticipationTabViewModel : ObservableObject [ObservableProperty] private bool _isReadOnly; public string SelectedSessionDisplay => SelectedSession?.Display ?? ""; - public List ActiveCompetencyCodes { get; private set; } = []; + public List ActiveCompetencies { get; private set; } = []; public ObservableCollection Sessions { get; } = []; public ObservableCollection StudentRows { get; } = []; @@ -132,14 +132,14 @@ public partial class ParticipationTabViewModel : ObservableObject { StudentRows.Clear(); CompetencyTagGroups.Clear(); - ActiveCompetencyCodes = []; + ActiveCompetencies = []; QuickInputCommand.NotifyCanExecuteChanged(); StatusQuickInputCommand.NotifyCanExecuteChanged(); RebuildColumnsSignal++; return; } - LoadCompetencyTags(value.Id); // sets ActiveCompetencyCodes first - LoadGrid(value.Id); // uses ActiveCompetencyCodes, fires RebuildColumnsSignal++ + LoadCompetencyTags(value.Id); // sets ActiveCompetencies first + LoadGrid(value.Id); // uses ActiveCompetencies, fires RebuildColumnsSignal++ } private void LoadGrid(Guid sessionId) @@ -159,7 +159,7 @@ public partial class ParticipationTabViewModel : ObservableObject var entry = entries.FirstOrDefault(e => e.StudentId == s.Id) ?? new ParticipationEntry { SessionId = sessionId, StudentId = s.Id }; - var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencyCodes); + var row = new ParticipationStudentRow(s.Id, s.FullName, entry, Aspects.ToList(), ActiveCompetencies); row.OnRatingChanged = (sid, key, val) => SaveRating(sessionId, sid, key, val); row.OnCompetencyRatingChanged = (sid, code, val) => SaveCompetencyRating(sessionId, sid, code, val); row.HomeworkChangedCallback = (sid, val) => SaveHomework(sessionId, sid, val); @@ -237,35 +237,31 @@ public partial class ParticipationTabViewModel : ObservableObject { CompetencyTagGroups.Clear(); var session = _sessions.GetById(sessionId); - ActiveCompetencyCodes = session?.CompetencyCodes?.ToList() ?? []; + ActiveCompetencies = session?.Competencies?.ToList() ?? []; if (!_subjectId.HasValue) return; - var active = ActiveCompetencyCodes.ToHashSet(); - - foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId)) - { - var group = new CompetencyTagGroup(domain.Name, domain.Code); - foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) - { - var tag = new CompetencyTag(item.Code, item.Description, active.Contains(item.Code)); - tag.OnChanged = (code, sel) => OnTagToggled(code, sel); - group.Items.Add(tag); - } - if (group.Items.Count > 0) - CompetencyTagGroups.Add(group); - } + var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId); + foreach (var group in CompetencyTagGroupBuilder.Build(catalog, ActiveCompetencies, OnTagToggled)) + CompetencyTagGroups.Add(group); } - private void OnTagToggled(string code, bool selected) + private void OnTagToggled(CompetencyTag tag) { if (SelectedSession is null) return; var session = _sessions.GetById(SelectedSession.Id); if (session is null) return; - if (selected) { if (!session.CompetencyCodes.Contains(code)) session.CompetencyCodes.Add(code); } - else { session.CompetencyCodes.Remove(code); } + if (tag.IsSelected) + { + if (!session.Competencies.Any(r => r.ItemId == tag.ItemId)) + session.Competencies.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description }); + } + else + { + session.Competencies.RemoveAll(r => r.ItemId == tag.ItemId); + } _sessions.Save(session); - ActiveCompetencyCodes = session.CompetencyCodes.ToList(); + ActiveCompetencies = session.Competencies.ToList(); if (StudentCompetencyRatingsVisible) LoadGrid(SelectedSession.Id); // reloads rows with updated cells, fires RebuildColumnsSignal++ } @@ -369,22 +365,25 @@ public partial class ParticipationTabViewModel : ObservableObject _entries.Save(entry); } - private void SaveCompetencyRating(Guid sessionId, Guid studentId, string code, int? value) + // "key" ist der Kompetenz-ItemId als String (siehe ParticipationStudentRow/RatingCell) - reiner + // In-Memory-Transport zwischen Zelle und Save-Callback, nie persistiert. + private void SaveCompetencyRating(Guid sessionId, Guid studentId, string key, int? value) { + var itemId = Guid.Parse(key); var entry = _entries.GetBySessionAndStudent(sessionId, studentId) ?? new ParticipationEntry { SessionId = sessionId, StudentId = studentId, }; - var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); + var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == itemId); if (value is null) { if (existing is not null) entry.CompetencyRatings.Remove(existing); } else { - if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { Code = code, Value = value.Value }); + if (existing is null) entry.CompetencyRatings.Add(new CompetencyRating { ItemId = itemId, Value = value.Value }); else existing.Value = value.Value; } _entries.Save(entry); @@ -425,7 +424,7 @@ public partial class ParticipationStudentRow : ObservableObject public Action? DayHighlightChangedCallback { get; set; } public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry, - List aspects, List competencyCodes) + List aspects, List competencies) { StudentId = id; Name = name; @@ -442,10 +441,12 @@ public partial class ParticipationStudentRow : ObservableObject Cells.Add(cell); } - foreach (var code in competencyCodes) + // AspectKey trägt hier den Kompetenz-ItemId als String - reiner In-Memory-Transport + // zwischen Zelle und Save-Callback (siehe SaveCompetencyRating), nie persistiert. + foreach (var competency in competencies) { - var existing = entry.CompetencyRatings.FirstOrDefault(r => r.Code == code); - var cell = new RatingCell(id, code, existing?.Value); + var existing = entry.CompetencyRatings.FirstOrDefault(r => r.ItemId == competency.ItemId); + var cell = new RatingCell(id, competency.ItemId.ToString("D"), existing?.Value); cell.OnChanged = (sid, key, val) => OnCompetencyRatingChanged?.Invoke(sid, key, val); CompetencyCells.Add(cell); } @@ -726,37 +727,6 @@ public partial class RatingCell : ObservableObject private void UpdateLabel() => DisplayLabel = ParticipationRatingScale.DisplayLabel(Type, Value); } -// ── Kompetenz-Tags ──────────────────────────────────────────────────────────── - -public class CompetencyTagGroup(string name, string code) -{ - public string Name { get; } = name; - public string Code { get; } = code; - public string DisplayName { get; } = string.IsNullOrEmpty(code) ? name : $"{name} ({code})"; - public List Items { get; } = []; -} - -public partial class CompetencyTag : ObservableObject -{ - public string Code { get; } - public string Description { get; } - public string Display { get; } - - [ObservableProperty] private bool _isSelected; - - public Action? OnChanged { get; set; } - - public CompetencyTag(string code, string description, bool isSelected) - { - Code = code; - Description = description; - Display = string.IsNullOrEmpty(code) ? description : $"[{code}] {description}"; - _isSelected = isSelected; - } - - partial void OnIsSelectedChanged(bool value) => OnChanged?.Invoke(Code, value); -} - // ── Hilfsklassen ────────────────────────────────────────────────────────────── public class AspectColumnDef diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 113be43..a79ff12 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -586,7 +586,7 @@ public partial class UnitDialogViewModel : ObservableObject private readonly int _gradeLevel; private readonly Guid _competencyCatalogVersionId; private readonly Unit? _editingUnit; - private readonly List _competencyCodes; + private readonly List _competencyRefs; [ObservableProperty] private string _title = ""; [ObservableProperty] private string _startDateText = ""; @@ -601,8 +601,8 @@ public partial class UnitDialogViewModel : ObservableObject public string[] StatusOptions => UnitStatusDisplay.Options; public ObservableCollection CompetencyTagGroups { get; } = []; - public string CompetencySummary => _competencyCodes.Count == 0 - ? "Keine Kompetenzen" : $"{_competencyCodes.Count} Kompetenz(en)"; + public string CompetencySummary => _competencyRefs.Count == 0 + ? "Keine Kompetenzen" : $"{_competencyRefs.Count} Kompetenz(en)"; /// Gruppe + Fach kommen von der Lerngruppe, nicht editierbar (jede Gruppe unterrichtet ein /// Fach) — beide zusammen zeigen, da dieselbe Klasse in mehreren Fächern (mehrere @@ -638,7 +638,7 @@ public partial class UnitDialogViewModel : ObservableObject GroupSubjectDisplay = string.IsNullOrWhiteSpace(subjectName) ? $"{groupName} · kein Fach hinterlegt (siehe Lerngruppe)" : $"{groupName} · {subjectName}"; - _competencyCodes = editingUnit is not null ? [.. editingUnit.Competencies] : []; + _competencyRefs = editingUnit is not null ? [.. editingUnit.Competencies] : []; BuildCompetencyTagGroups(); if (editingUnit is not null) @@ -655,24 +655,22 @@ public partial class UnitDialogViewModel : ObservableObject { CompetencyTagGroups.Clear(); if (!_subjectId.HasValue) return; - var selected = _competencyCodes.ToHashSet(); - foreach (var domain in _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId)) - { - var group = new CompetencyTagGroup(domain.Name, domain.Code); - foreach (var item in domain.Items.OrderBy(i => i.SortOrder)) - { - var tag = new CompetencyTag(item.Code, item.Description, selected.Contains(item.Code)); - tag.OnChanged = OnCompetencyToggled; - group.Items.Add(tag); - } - if (group.Items.Count > 0) CompetencyTagGroups.Add(group); - } + var catalog = _competencyDomains.GetBySubjectAndGrade(_subjectId.Value, _gradeLevel, _competencyCatalogVersionId); + foreach (var group in CompetencyTagGroupBuilder.Build(catalog, _competencyRefs, OnCompetencyToggled)) + CompetencyTagGroups.Add(group); } - private void OnCompetencyToggled(string code, bool selected) + private void OnCompetencyToggled(CompetencyTag tag) { - if (selected) { if (!_competencyCodes.Contains(code)) _competencyCodes.Add(code); } - else _competencyCodes.Remove(code); + if (tag.IsSelected) + { + if (!_competencyRefs.Any(r => r.ItemId == tag.ItemId)) + _competencyRefs.Add(new CompetencyReference { ItemId = tag.ItemId, Code = tag.Code, Description = tag.Description }); + } + else + { + _competencyRefs.RemoveAll(r => r.ItemId == tag.ItemId); + } OnPropertyChanged(nameof(CompetencySummary)); } @@ -713,7 +711,7 @@ public partial class UnitDialogViewModel : ObservableObject Result.EndDate = endDate; Result.Status = UnitStatusDisplay.FromName(StatusName); Result.Notes = string.IsNullOrWhiteSpace(Notes) ? null : Notes.Trim(); - Result.Competencies = _competencyCodes; + Result.Competencies = _competencyRefs; if (Result.Status == UnitStatus.Active) { // Pro Lerngruppe gibt es genau einen aktuellen Arbeitskontext. Frühere laufende diff --git a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs index 479004d..b5014ac 100644 --- a/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs +++ b/LehrerApp.Desktop/Views/Groups/ParticipationTabView.axaml.cs @@ -81,14 +81,14 @@ public partial class ParticipationTabView : UserControl }); // Kompetenz-Spalten (opt-in) - if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencyCodes.Count > 0) + if (_vm.StudentCompetencyRatingsVisible && _vm.ActiveCompetencies.Count > 0) { - foreach (var (code, i) in _vm.ActiveCompetencyCodes.Select((c, i) => (c, i))) + foreach (var (competency, i) in _vm.ActiveCompetencies.Select((c, i) => (c, i))) { var idx = i; grid.Columns.Add(new DataGridTemplateColumn { - Header = code, + Header = string.IsNullOrEmpty(competency.Code) ? competency.Description : competency.Code, Width = new DataGridLength(80, DataGridLengthUnitType.Pixel), CellTemplate = BuildCellTemplate(idx, forCompetency: true, _vm.IsReadOnly), }); diff --git a/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs b/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs index 7dac985..df105e1 100644 --- a/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs +++ b/LehrerApp.Tests/CompetencyAnalysisServiceTests.cs @@ -8,6 +8,12 @@ public sealed class CompetencyAnalysisServiceTests { private readonly CompetencyAnalysisService _service = new(); + private static readonly CompetencyItem K1 = new() { Code = "K1", Description = "Erste Kompetenz" }; + private static readonly CompetencyItem K2 = new() { Code = "K2", Description = "Zweite Kompetenz", SortOrder = 1 }; + + private static CompetencyReference Ref(CompetencyItem item) => + new() { ItemId = item.Id, Code = item.Code, Description = item.Description }; + [Fact] public void Analyze_UnterscheidetPlanungBehandlungUndPruefung() { @@ -15,16 +21,16 @@ public sealed class CompetencyAnalysisServiceTests var catalog = Catalog(); var units = new List { - new() { GroupId = groupId, Status = UnitStatus.Planned, Competencies = ["K1"] }, - new() { GroupId = groupId, Status = UnitStatus.Active, Competencies = ["K1"] }, - new() { GroupId = groupId, Status = UnitStatus.Completed, Competencies = ["K1"] }, + new() { GroupId = groupId, Status = UnitStatus.Planned, Competencies = [Ref(K1)] }, + new() { GroupId = groupId, Status = UnitStatus.Active, Competencies = [Ref(K1)] }, + new() { GroupId = groupId, Status = UnitStatus.Completed, Competencies = [Ref(K1)] }, }; var exams = new List { new() { GroupId = groupId, Status = ExamStatus.Planned, - Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] }, + Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }] }, new() { GroupId = groupId, Status = ExamStatus.Graded, - Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }] }, + Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }] }, }; var item = Assert.Single(_service.Analyze(catalog, units, exams, [])); @@ -46,8 +52,8 @@ public sealed class CompetencyAnalysisServiceTests Status = ExamStatus.Graded, Tasks = [ - new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }, - new ExamTask { MaxPoints = 20, CompetencyCodes = ["K1"] }, + new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }, + new ExamTask { MaxPoints = 20, Competencies = [Ref(K1)] }, ], }; var results = new List @@ -69,13 +75,13 @@ public sealed class CompetencyAnalysisServiceTests } [Fact] - public void Analyze_AufgabeMitMehrerenCodes_WertetJedeZugeordneteKompetenzAus() + public void Analyze_AufgabeMitMehrerenKompetenzen_WertetJedeZugeordneteKompetenzAus() { var studentId = Guid.NewGuid(); var exam = new Exam { Status = ExamStatus.Returned, - Tasks = [new ExamTask { MaxPoints = 8, CompetencyCodes = ["K1", "K2"] }], + Tasks = [new ExamTask { MaxPoints = 8, Competencies = [Ref(K1), Ref(K2)] }], }; var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [6] }; @@ -91,7 +97,7 @@ public sealed class CompetencyAnalysisServiceTests var exam = new Exam { Status = ExamStatus.Conducted, - Tasks = [new ExamTask { MaxPoints = 10, CompetencyCodes = ["K1"] }], + Tasks = [new ExamTask { MaxPoints = 10, Competencies = [Ref(K1)] }], }; var result = new ExamResult { ExamId = exam.Id, StudentId = studentId, Points = [5] }; @@ -103,30 +109,59 @@ public sealed class CompetencyAnalysisServiceTests } [Fact] - public void Analyze_VerwendeterUnbekannterCode_BleibtInUebersichtSichtbar() + public void Analyze_GeloeschteKompetenz_BleibtMitGespeicherterMomentaufnahmeSichtbar() { - var unit = new Unit { Status = UnitStatus.Active, Competencies = ["ALT1"] }; + // ItemId zeigt auf kein (mehr) existierendes Katalog-Item (z.B. gelöscht) - Code/Description + // stammen aus der zum Zuordnungszeitpunkt gespeicherten Momentaufnahme. + var reference = new CompetencyReference + { + ItemId = Guid.NewGuid(), Code = "ALT1", Description = "Alte, inzwischen gelöschte Kompetenz", + }; + var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] }; var analysis = _service.Analyze(Catalog(), [unit], [], []); var unknown = Assert.Single(analysis, x => x.Code == "ALT1"); Assert.Equal("Nicht im Katalog", unknown.DomainName); + Assert.Equal("Alte, inzwischen gelöschte Kompetenz", unknown.Description); Assert.Equal(1, unknown.TaughtUnitCount); } + [Fact] + public void Analyze_NieAufgeloesteReferenz_OhneBeschreibung_ZeigtFallbacktext() + { + // ItemId == Guid.Empty simuliert eine nie aufgelöste Referenz (z.B. aus einem + // plattformübergreifenden Import über PlanningExchangeService) ohne gespeicherte Beschreibung. + var reference = new CompetencyReference { ItemId = Guid.Empty, Code = "IMPORT1", Description = "" }; + var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] }; + + var analysis = _service.Analyze(Catalog(), [unit], [], []); + + var unknown = Assert.Single(analysis, x => x.Code == "IMPORT1"); + Assert.Contains("nicht (mehr) im aktuellen Katalog", unknown.Description); + } + + [Fact] + public void Analyze_UmbenannteKompetenz_ZeigtLiveFassungStattMomentaufnahme() + { + // Referenz trägt eine veraltete Momentaufnahme (Description von vor dem Rename) - solange + // ItemId noch im Katalog auflösbar ist, muss die LIVE-Fassung gewinnen. + var reference = new CompetencyReference { ItemId = K1.Id, Code = "K1", Description = "Veraltete Beschreibung" }; + var unit = new Unit { Status = UnitStatus.Active, Competencies = [reference] }; + + var item = Assert.Single(_service.Analyze(Catalog(), [unit], [], [])); + + Assert.Equal("Erste Kompetenz", item.Description); + Assert.Equal("Bereich", item.DomainName); + } + private static List Catalog(bool includeSecond = false) => [ new CompetencyDomain { Name = "Bereich", Code = "B", - Items = includeSecond - ? - [ - new CompetencyItem { Code = "K1", Description = "Erste Kompetenz", SortOrder = 0 }, - new CompetencyItem { Code = "K2", Description = "Zweite Kompetenz", SortOrder = 1 }, - ] - : [new CompetencyItem { Code = "K1", Description = "Erste Kompetenz" }], + Items = includeSecond ? [K1, K2] : [K1], }, ]; } diff --git a/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs index 3431877..538cbfa 100644 --- a/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs +++ b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs @@ -183,6 +183,7 @@ public sealed class CompetencyCatalogImportServiceTests Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel && x.VersionId == versionId).ToList(); public List GetBySubjectAndVersion(Guid subjectId, Guid versionId) => Current.Where(x => x.SubjectId == subjectId && x.VersionId == versionId).ToList(); + public List GetAll() => Current.ToList(); public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id); public void Save(CompetencyDomain domain) => Current.Add(domain); diff --git a/TODO.md b/TODO.md index 6dcac5a..914939b 100644 --- a/TODO.md +++ b/TODO.md @@ -4852,9 +4852,26 @@ von 4.2.2) der Tab "Kürzel-Katalog" für die Von/Nach-Kürzel des Stundenverlau - [ ] **16.4** Redundant gespeicherte Bewertungswerte (`ExamResult.TotalPoints`, berechnete Note) entweder ableiten oder zusammen mit einer unveränderlichen Version des verwendeten Notenschlüssels als historischen Snapshot speichern. -- [ ] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei +- [x] **16.5** Kompetenzzuordnungen auf stabile `CompetencyItem.Id` umstellen; Code/Beschreibung bei Bedarf zusätzlich als historischen Snapshot speichern, damit Umbenennungen keine alten Klausur- oder Sitzungsreferenzen brechen. + Neuer Werttyp `CompetencyReference` (`ItemId` + Code/Description-Momentaufnahme, siehe + Datenmodell.md "Bewusst gespeicherte Momentaufnahmen") ersetzt `List` in + `Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies` (umbenannt von + `CompetencyCodes`), `ParticipationSession.Competencies` (umbenannt) und + `CompetencyRating.ItemId` (umbenannt von `.Code`). Solange `ItemId` noch im Katalog auflösbar + ist, zeigt die Analyse/UI die live Fassung (Rename wirkt sofort); ohne Treffer erscheint die + Momentaufnahme in einer eigenen "Nicht mehr im Katalog"-Gruppe, statt zu verschwinden. + Migration (Schema-Version 6, `LiteDbContext.MigrateCompetencyReferences`) löst alte Codes + gegen den bestehenden Katalog auf; unauflösbare Codes bleiben als Text erhalten + (`ItemId = Guid.Empty`), unauflösbare Mitarbeit-Ratings werden verworfen. Die dreifach + duplizierte Tag-Auswahl-UI (Einheit/Klausuraufgabe/Mitarbeit-Sitzung) wurde dabei in + `CompetencyTagGroupBuilder`/`CompetencyTagging.cs` konsolidiert. MCP: + `add_lesson_competency`/`remove_lesson_competency` nehmen jetzt `itemId` statt `code` und + validieren gegen den Katalog (vorher ungeprüft). `PlanningExchangeService` überträgt weiterhin + nur Code-Text (nie IDs, wie im Format ohnehin vorgesehen) und speichert importierte + Kompetenzen bewusst als nicht aufgelöste Referenz statt sie gegen den Katalog der + Zielinstallation zu raten. - [ ] **16.6** Navigation und manuelle Callback-Verdrahtung in `App.axaml.cs` langfristig durch einen testbaren Navigationsdienst oder Messenger ersetzen; statischen Servicezugriff abbauen. diff --git a/docs/Datenmodell.md b/docs/Datenmodell.md index 49e8036..f11b3dc 100644 --- a/docs/Datenmodell.md +++ b/docs/Datenmodell.md @@ -98,6 +98,18 @@ Einige berechnete Werte bleiben absichtlich gespeichert: - `Exam.GradingKey` hält den für die konkrete Klausur verwendeten Notenschlüssel fest und ist unabhängig von später geänderten Vorlagen. - `Exam.Tasks` hält die Aufgabenstruktur der konkreten Klausur fest. +- `CompetencyReference` (`Unit.Competencies`, `Lesson.Competencies`, `ExamTask.Competencies`, + `ParticipationSession.Competencies`) verweist über `ItemId` auf ein `CompetencyItem`, hält aber + zusätzlich `Code`/`Description` zum Zuordnungszeitpunkt fest (TODO 16.5, abgelöst von einer reinen + Freitext-Code-Zuordnung). Solange `ItemId` noch im Katalog auflösbar ist, zeigt die Anwendung die + **live** Fassung (ein Rename im Katalog wirkt sich überall sofort aus); wird das Item gelöscht, + bleibt die hier gespeicherte historische Fassung lesbar statt spurlos zu verschwinden. + `ItemId == Guid.Empty` markiert eine nie aufgelöste Referenz (z.B. aus einem + plattformübergreifenden `PlanningExchangeService`-Import, der bewusst keine installationsfremden + IDs überträgt) — Code/Description sind dann die einzige Information. + `CompetencyRating.ItemId` (Mitarbeit, je Schüler) referenziert ebenso per Id, aber ohne eigenen + Snapshot — die Identität kommt aus der Zuordnungsliste der zugehörigen `ParticipationSession`, + die bereits eine Momentaufnahme trägt. Änderungen an Aufgaben oder Notenschlüssel müssen die betroffenen Ergebnisse kontrolliert neu berechnen. Diese Werte sind daher fachliche Momentaufnahmen und