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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); } }