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>
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-19 00:41:54 +02:00
1 parent f97edff2fe
commit dc2f58c4dc
28 files changed
+691 -202

No files matched your search

@@ -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<CompetencyDomain> 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<CompetencyDomain> GetAll();
CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain);
void Delete(Guid id);
+14
View File
@@ -40,3 +40,17 @@ public class CompetencyItem
public string Description { get; set; } = "";
public int SortOrder { get; set; }
}
/// <summary>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.</summary>
public class CompetencyReference
{
public Guid ItemId { get; set; }
public string Code { get; set; } = "";
public string Description { get; set; } = "";
}
+1 -1
View File
@@ -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<string> CompetencyCodes { get; set; } = [];
public List<CompetencyReference> Competencies { get; set; } = [];
}
public class GradingKeyEntry
{
+3 -3
View File
@@ -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<string> CompetencyCodes { get; set; } = [];
public List<CompetencyReference> 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
+6 -6
View File
@@ -37,7 +37,7 @@ public class Unit
public string Title { get; set; } = "";
public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public List<string> Competencies { get; set; } = [];
public List<CompetencyReference> 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 (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<string> Competencies { get; set; } = [];
/// Kompetenzen aus dem Katalog, die dieser Stunde als Ganzes zugeordnet sind — analog zu
/// <see cref="Unit.Competencies"/>, aber auf Stundenebene statt Einheitenebene. Bewusst noch
/// keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/> (siehe TODO.md 4.5.8, weiterhin
/// offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<CompetencyReference> 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
@@ -32,31 +32,54 @@ public sealed class CompetencyAnalysisService
IReadOnlyList<ExamResult> results)
{
var builders = new List<ItemBuilder>();
var byCode = new Dictionary<string, ItemBuilder>(StringComparer.OrdinalIgnoreCase);
var byId = new Dictionary<Guid, ItemBuilder>();
// 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<string, ItemBuilder>(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<string> NormalizeCodes(IEnumerable<string> codes) =>
codes.Where(HasCode).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase);
private static IEnumerable<CompetencyReference> NormalizeReferences(IEnumerable<CompetencyReference> references) =>
references.DistinctBy(x => x.ItemId);
private sealed class ItemBuilder(
string domainName, string domainCode, string code, string description)
+89 -2
View File
@@ -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<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()
{
+114 -1
View File
@@ -15,7 +15,7 @@ public class LiteDbContext : IDisposable
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
/// <see cref="RunVersionedMigrations"/> 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
}
}
/// <summary>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.</summary>
private void MigrateCompetencyReferences()
{
var byCode = new Dictionary<string, CompetencyItem>(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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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);
@@ -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<CompetencyDomain> GetAll() => db.CompetencyDomains.FindAll().ToList();
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d)
{
@@ -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());
@@ -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<CompetencyDomain> { new() { Name = "Bereich", Items = [item] } };
var selected = new List<CompetencyReference> { 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<CompetencyDomain>
{
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<CompetencyReference> { 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<CompetencyDomain> { 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);
}
}
+1
View File
@@ -397,6 +397,7 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
public List<CompetencyDomain> 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<CompetencyDomain> 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);
+43 -13
View File
@@ -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);
@@ -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]
@@ -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);
@@ -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,
+7 -2
View File
@@ -34,9 +34,14 @@ public record LessonPhaseDto(
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
/// <summary>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.</summary>
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<string> Competencies,
string? Homework, string? PlanningIdeas, LessonStatus Status, List<CompetencyReferenceDto> Competencies,
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
/// <summary>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<string> Competencies);
UnitStatus Status, List<CompetencyReferenceDto> Competencies);
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
@@ -25,7 +25,8 @@ namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// (siehe McpServerHostedService) und mit besonders deutlicher Bestätigungsnachricht.</summary>
public class LessonPlanTools(
IUnitRepository units, ILessonRepository lessons, IGroupRepository groups,
IAttachmentStorage attachments, IMcpConfirmationService confirmation)
IAttachmentStorage attachments, ICompetencyDomainRepository competencyDomains,
IMcpConfirmationService confirmation)
{
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (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<WriteResultDto> 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<WriteResultDto> 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<CompetencyReferenceDto> ToDto(List<CompetencyReference> competencies) =>
competencies.Select(c => new CompetencyReferenceDto(c.ItemId, c.Code, c.Description)).ToList();
}
@@ -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)],
@@ -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<CompetencyTag> 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;
/// <summary>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.</summary>
public Action<CompetencyTag>? 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
{
/// <summary>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.</summary>
public static List<CompetencyTagGroup> Build(
IReadOnlyList<CompetencyDomain> catalog, List<CompetencyReference> selected,
Action<CompetencyTag> onChanged)
{
var groups = new List<CompetencyTagGroup>();
var matchedIds = new HashSet<Guid>();
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;
}
}
@@ -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<string> competencyCodes)
private void AddTaskInternal(string? title, double maxPoints, double weight, List<CompetencyReference> 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<CompetencyTagGroup> BuildCompetencyTagGroups(List<string> selectedCodes)
private List<CompetencyTagGroup> BuildCompetencyTagGroups(List<CompetencyReference> selected)
{
var groups = new List<CompetencyTagGroup>();
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<string> CompetencyCodes { get; }
public List<CompetencyReference> Competencies { get; }
public ObservableCollection<CompetencyTagGroup> 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<ExamTaskEditItem>? OnRemove { get; set; }
@@ -381,12 +375,12 @@ public partial class ExamTaskEditItem : ObservableObject
public Action<ExamTaskEditItem>? OnMoveDown { get; set; }
public ExamTaskEditItem(string? title, double maxPoints, double weight,
List<string> competencyCodes, List<CompetencyTagGroup> tagGroups)
List<CompetencyReference> competencies, List<CompetencyTagGroup> 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,
};
}
@@ -42,7 +42,7 @@ public partial class ParticipationTabViewModel : ObservableObject
[ObservableProperty] private bool _isReadOnly;
public string SelectedSessionDisplay => SelectedSession?.Display ?? "";
public List<string> ActiveCompetencyCodes { get; private set; } = [];
public List<CompetencyReference> ActiveCompetencies { get; private set; } = [];
public ObservableCollection<ParticipationSessionItem> Sessions { get; } = [];
public ObservableCollection<ParticipationStudentRow> 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<Guid, DayHighlightKind?>? DayHighlightChangedCallback { get; set; }
public ParticipationStudentRow(Guid id, string name, ParticipationEntry entry,
List<AspectColumnDef> aspects, List<string> competencyCodes)
List<AspectColumnDef> aspects, List<CompetencyReference> 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<CompetencyTag> 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<string, bool>? 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
@@ -586,7 +586,7 @@ public partial class UnitDialogViewModel : ObservableObject
private readonly int _gradeLevel;
private readonly Guid _competencyCatalogVersionId;
private readonly Unit? _editingUnit;
private readonly List<string> _competencyCodes;
private readonly List<CompetencyReference> _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<CompetencyTagGroup> 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
@@ -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),
});
@@ -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<Unit>
{
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<Exam>
{
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<ExamResult>
@@ -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<CompetencyDomain> 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],
},
];
}
@@ -183,6 +183,7 @@ public sealed class CompetencyCatalogImportServiceTests
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel && x.VersionId == versionId).ToList();
public List<CompetencyDomain> GetBySubjectAndVersion(Guid subjectId, Guid versionId) =>
Current.Where(x => x.SubjectId == subjectId && x.VersionId == versionId).ToList();
public List<CompetencyDomain> GetAll() => Current.ToList();
public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
public void Save(CompetencyDomain domain) => Current.Add(domain);
+18 -1
View File
@@ -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<string>` 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.
+12
View File
@@ -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