Files
LehrerApp/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs
T
adminandClaude Sonnet 5 2005b73a16 KI-Backend: deutsches Datumsformat fixen, Fortschrittsanzeige + Nachfassen im Dialog
Live-Test lieferte JsonException: DateOnly/TimeOnly hatten keinen Converter
fürs im Systemprompt dokumentierte deutsche Format (TT.MM.JJJJ/HH:mm), .NET
nutzte stattdessen ISO 8601 in beide Richtungen. Neue Converter mit Fallback
aufs allgemeine Parsen.

AiAssistDialog: indeterminierter ProgressBar statt nur Text während der
Anfrage. Neuer Button "Erneut anfragen" erlaubt Nachfassen mit geänderter
Anweisung, ohne den Dialog neu zu starten — schickt die aktuell angehakten
Vorschläge als Entwurfskontext mit (AiPlanningService.MergeDraft), damit die
KI auf dem noch ungespeicherten Stand aufbaut. Side-by-side-Vergleich beider
Entwürfe als 4.5.18 zurückgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 16:39:46 +02:00

284 lines
12 KiB
C#

using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für die reinen (nur Repository-Lesezugriff, kein Netzwerk) Teile von AiPlanningService:
/// BuildContext (Export) und ApplyResponse (Import). Login/GetBalanceAsync/RequestPlanAsync
/// brauchen einen echten HTTP-Endpunkt und sind nicht Teil dieser Tests (siehe Planungsdokument,
/// Abschnitt "Nicht ohne echtes Deployment ... verifizierbar").
public sealed class AiPlanningServiceTests
{
private static AiPlanningService Build(FakeLessons lessons, FakeGroups groups, FakeSubjects subjects,
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
[Fact]
public void BuildContext_FuelltGruppenUndFachKontext()
{
var subject = new Subject { Name = "Chemie" };
var group = new LearningGroup { Name = "9c", SubjectId = subject.Id, GradeLevel = 9 };
var unit = new Unit { GroupId = group.Id, Title = "Redoxreaktionen" };
var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var context = service.BuildContext(unit, "Testanweisung");
Assert.Equal(unit.Id, context.Id);
Assert.Equal("Redoxreaktionen", context.Title);
Assert.Equal("Chemie", context.SubjectName);
Assert.Equal(9, context.GradeLevel);
Assert.Equal("9c", context.GroupName);
}
[Fact]
public void BuildContext_FiltertKompetenzkatalogNachFachUndStufe()
{
var subject = new Subject { Name = "Chemie" };
var group = new LearningGroup { SubjectId = subject.Id, GradeLevel = 9 };
var unit = new Unit { GroupId = group.Id, Title = "T" };
var domains = new FakeCompetencyDomains();
domains.Add(new CompetencyDomain
{
SubjectId = subject.Id, GradeLevel = 9, Name = "Chemische Reaktionen",
Items = [new CompetencyItem { Code = "C1", Description = "Redoxreaktionen erklären" }],
});
var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]),
domains, new FakeAlternativeLessonPaths([]));
var context = service.BuildContext(unit, "");
var catalogDomain = Assert.Single(context.CompetencyCatalog);
Assert.Equal("Chemische Reaktionen", catalogDomain.Name);
var item = Assert.Single(catalogDomain.Items);
Assert.Equal("C1", item.Code);
Assert.Equal("Redoxreaktionen erklären", item.Description);
}
[Fact]
public void BuildContext_EnthaeltVorhandeneLessonsMitEchtenIds()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var lesson = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Erste Stunde" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var context = service.BuildContext(unit, "");
var aiLesson = Assert.Single(context.Lessons);
Assert.Equal(lesson.Id, aiLesson.Id);
Assert.Equal("Erste Stunde", aiLesson.Topic);
}
[Fact]
public void BuildContext_LoestAlternativePathNameAuf()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var path = new AlternativeLessonPath { Name = "Vertiefung" };
var lesson = new Lesson
{
UnitId = unit.Id, GroupId = group.Id, Topic = "Stunde",
Phases = [new LessonPhaseStep { Name = "Erarbeitung", AlternativePathId = path.Id }],
};
var lessons = new FakeLessons();
lessons.Add(lesson);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([path]));
var context = service.BuildContext(unit, "");
var phase = Assert.Single(Assert.Single(context.Lessons).Phases);
Assert.Equal("Vertiefung", phase.AlternativePathName);
Assert.Contains(context.AlternativePathCatalog, p => p.Name == "Vertiefung" && p.Id == path.Id);
}
[Fact]
public void BuildContext_DraftOverride_ErsetztBestehendeLessonMitGleicherId()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alter Titel" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var draft = new List<AiLesson> { new() { Id = existing.Id, Topic = "Neuer Entwurfstitel", Phases = [] } };
var context = service.BuildContext(unit, "", draft);
var aiLesson = Assert.Single(context.Lessons);
Assert.Equal(existing.Id, aiLesson.Id);
Assert.Equal("Neuer Entwurfstitel", aiLesson.Topic);
}
[Fact]
public void BuildContext_DraftOverride_HaengtNeueEntwurfLessonsOhneIdAn()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Bestehend" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var draft = new List<AiLesson> { new() { Id = null, Topic = "Neu aus Runde 1", Phases = [] } };
var context = service.BuildContext(unit, "", draft);
Assert.Equal(2, context.Lessons.Count);
Assert.Contains(context.Lessons, l => l.Topic == "Bestehend");
Assert.Contains(context.Lessons, l => l.Topic == "Neu aus Runde 1" && l.Id == null);
}
[Fact]
public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson> { new() { Id = existing.Id, Topic = "Neu formuliert" } };
var result = service.ApplyResponse(unit, accepted);
var lesson = Assert.Single(result);
Assert.Equal(existing.Id, lesson.Id);
Assert.Equal("Neu formuliert", lesson.Topic);
Assert.Equal(unit.Id, lesson.UnitId);
Assert.Equal(group.Id, lesson.GroupId);
}
/// Die KI kennt/liefert keinen Status — ohne diese Absicherung würde eine bereits gehaltene
/// Stunde durch eine übernommene KI-Änderung stillschweigend auf "Geplant" zurückgesetzt.
[Fact]
public void ApplyResponse_BekannteId_BehaeltStatusDerBestehendenLessonBei()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt", Status = LessonStatus.Conducted };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson> { new() { Id = existing.Id, Topic = "Nachträglich präzisiert" } };
var result = service.ApplyResponse(unit, accepted);
Assert.Equal(LessonStatus.Conducted, Assert.Single(result).Status);
}
/// Umfangs-Umschalter (Nutzer-Nachtrag): "Einheit umplanen ohne Stunden zu ändern" muss auch
/// dann greifen, wenn die KI die Anweisung im Systemprompt ignoriert und trotzdem eine
/// bestehende Id zurückgibt — client-seitig hart durchgesetzt, nicht nur per Prompt erbeten.
[Fact]
public void ApplyResponse_AenderungVerboten_VerwirftUpdateBestehenderLesson()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson>
{
new() { Id = existing.Id, Topic = "Sollte verworfen werden" },
new() { Id = null, Topic = "Neue Stunde bleibt erlaubt" },
};
var result = service.ApplyResponse(unit, accepted, allowModifyingExistingLessons: false);
var lesson = Assert.Single(result);
Assert.Equal("Neue Stunde bleibt erlaubt", lesson.Topic);
}
[Fact]
public void ApplyResponse_NullId_WirdAlsNeueLessonBehandelt()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson> { new() { Id = null, Topic = "Ganz neu" } };
var result = service.ApplyResponse(unit, accepted);
var lesson = Assert.Single(result);
Assert.NotEqual(Guid.Empty, lesson.Id);
Assert.Equal("Ganz neu", lesson.Topic);
}
/// Zentraler Sicherheitstest (siehe Planungsdokument): eine von der KI zurückgegebene Id, die
/// zu keiner tatsächlich zur Einheit gehörenden Lesson passt, darf NIE als Update interpretiert
/// werden — sonst könnte eine halluzinierte/fremde Id im schlimmsten Fall eine fremde Lesson
/// überschreiben. Sie muss stattdessen wie eine neue Lesson behandelt werden (frische Id).
[Fact]
public void ApplyResponse_UnbekannteFremdeId_WirdNieAlsUpdateUebernommen()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Bestehende Stunde" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var foreignId = Guid.NewGuid(); // gehört zu keiner Lesson dieser Einheit
var accepted = new List<AiLesson> { new() { Id = foreignId, Topic = "Verdächtig" } };
var result = service.ApplyResponse(unit, accepted);
var lesson = Assert.Single(result);
Assert.NotEqual(foreignId, lesson.Id);
Assert.NotEqual(existing.Id, lesson.Id);
}
[Fact]
public void ApplyResponse_LoestAlternativePathNameZurueckZuId_KeinTrefferBleibtNull()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var path = new AlternativeLessonPath { Name = "Förderung" };
var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([path]));
var accepted = new List<AiLesson>
{
new()
{
Topic = "T", Phases =
[
new AiPhaseStep { Name = "A", AlternativePathName = "Förderung" },
new AiPhaseStep { Name = "B", AlternativePathName = "Unbekannter Pfad" },
],
},
};
var result = service.ApplyResponse(unit, accepted);
var phases = Assert.Single(result).Phases;
Assert.Equal(path.Id, phases[0].AlternativePathId);
Assert.Null(phases[1].AlternativePathId);
}
}