KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)
KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/) sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf, Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie als Update übernommen, sondern immer als neue Stunde behandelt. Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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 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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class AiSettingsServiceTests
|
||||
{
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-aisettingssvc-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetEnabled_PersistiertUeberNeueInstanz()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new AiSettingsService(path).SetEnabled(true);
|
||||
|
||||
var reloaded = new AiSettingsService(path);
|
||||
|
||||
Assert.True(reloaded.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCredentialsAndToken_TokenIstVerschluesseltAbrufbar()
|
||||
{
|
||||
var service = new AiSettingsService(BuildTempPath());
|
||||
|
||||
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||
|
||||
Assert.True(service.IsLoggedIn);
|
||||
Assert.Equal("sebastian", service.Username);
|
||||
Assert.Equal("geheimes-token-123", service.GetToken());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Token_UeberlebtNeueInstanzMitDemselbenPfad()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
new AiSettingsService(path).SetCredentialsAndToken("sebastian", "token-abc");
|
||||
|
||||
var reloaded = new AiSettingsService(path);
|
||||
|
||||
Assert.True(reloaded.IsLoggedIn);
|
||||
Assert.Equal("token-abc", reloaded.GetToken());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenDateiEnthaeltNichtDenKlartext()
|
||||
{
|
||||
var path = BuildTempPath();
|
||||
var service = new AiSettingsService(path);
|
||||
service.SetCredentialsAndToken("sebastian", "geheimes-token-123");
|
||||
|
||||
var raw = File.ReadAllText(Path.Combine(path, "ai-settings.json"));
|
||||
|
||||
Assert.DoesNotContain("geheimes-token-123", raw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logout_EntferntToken()
|
||||
{
|
||||
var service = new AiSettingsService(BuildTempPath());
|
||||
service.SetCredentialsAndToken("sebastian", "token-abc");
|
||||
|
||||
service.Logout();
|
||||
|
||||
Assert.False(service.IsLoggedIn);
|
||||
Assert.Null(service.GetToken());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,33 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
// Einfache In-Memory-Fakes der Repository-Schnittstellen, damit ViewModel-Tests ohne echte
|
||||
// LiteDB-Anbindung laufen. Bewusst schlank gehalten: nur was die getesteten ViewModels brauchen.
|
||||
|
||||
public static class TestSupport
|
||||
{
|
||||
/// Für Tests, die eine echte AiSettingsService-Instanz brauchen (dateibasiert wie
|
||||
/// PeriodScheduleService & Co.) — eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht
|
||||
/// gegenseitig über dieselbe ai-settings.json/ai-token.key stören.
|
||||
public static AiSettingsService BuildAiSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-aisettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new AiSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Für Tests, die nur einen validen AiPlanningService zum Durchreichen brauchen (z.B. weil
|
||||
/// SettingsViewModel/PlanningTabViewModel ihn im Konstruktor verlangen), nicht seine eigentliche
|
||||
/// Funktionalität testen — leere Fakes genügen, es wird kein echter HTTP-Aufruf ausgelöst,
|
||||
/// solange AiSettingsService.IsLoggedIn false ist.
|
||||
public static AiPlanningService BuildAiPlanningService() => new(
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
}
|
||||
|
||||
public class FakeStudents(List<Student> all) : IStudentRepository
|
||||
{
|
||||
private readonly Dictionary<Guid, StudentReferenceSummary> _references = [];
|
||||
@@ -209,11 +231,20 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
||||
|
||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => [];
|
||||
public CompetencyDomain? GetById(Guid id) => null;
|
||||
public void Save(CompetencyDomain domain) { }
|
||||
public void Delete(Guid id) { }
|
||||
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) { }
|
||||
private readonly List<CompetencyDomain> _all = [];
|
||||
public void Add(CompetencyDomain d) => _all.Add(d);
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel).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);
|
||||
public void DeleteBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
_all.RemoveAll(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel);
|
||||
public void ReplaceForSubjectAndGrade(Guid subjectId, int gradeLevel, List<CompetencyDomain> domains)
|
||||
{
|
||||
DeleteBySubjectAndGrade(subjectId, gradeLevel);
|
||||
_all.AddRange(domains);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeShorthandCodes(List<ShorthandCode> all) : IShorthandCodeRepository
|
||||
|
||||
@@ -21,7 +21,8 @@ public sealed class GroupDetailViewModelTests
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects, new FakeCompetencyDomains()));
|
||||
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()));
|
||||
|
||||
vm.LoadGroup(group.Id);
|
||||
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
@@ -18,7 +19,8 @@ public class PlanningTabViewModelTests
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
|
||||
TestSupport.BuildAiSettingsService());
|
||||
vm.Initialize(groupId);
|
||||
|
||||
return (vm, units, lessons, groupId);
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class SettingsViewModelTests
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath));
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -101,7 +101,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
|
||||
vm.SelectedStateName = "Bayern";
|
||||
|
||||
@@ -123,7 +123,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:00";
|
||||
vm.PeriodTimes[0].EndText = "08:45";
|
||||
@@ -149,7 +149,7 @@ public sealed class SettingsViewModelTests
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath));
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService());
|
||||
|
||||
vm.PeriodTimes[0].StartText = "08:45";
|
||||
vm.PeriodTimes[0].EndText = "08:00";
|
||||
|
||||
Reference in New Issue
Block a user