Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen (LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff). upload_worksheet/update_worksheet bewusst nicht umgesetzt: das Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert, damit Dialog und MCP-Tool nicht auseinanderdriften. Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude Desktops claude_desktop_config.json ein (Button in den Einstellungen, nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne eine nicht lesbare Konfigurationsdatei zu überschreiben. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
451 lines
19 KiB
C#
451 lines
19 KiB
C#
using LehrerApp.Core.Models;
|
|
using LehrerApp.Desktop.Services.Mcp;
|
|
using LehrerApp.Desktop.Services.Mcp.Tools;
|
|
using Xunit;
|
|
|
|
namespace LehrerApp.Desktop.Tests;
|
|
|
|
public sealed class McpToolsTests
|
|
{
|
|
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools()
|
|
{
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans",
|
|
"get_schedule", "get_students", "get_time_entries", "list_letter_templates",
|
|
"render_letter",
|
|
},
|
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
|
|
{
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
"add_lesson_phase", "create_grade_entry", "create_lesson", "create_time_entry",
|
|
"create_unit", "remove_lesson_phase", "update_lesson", "update_lesson_phase",
|
|
"update_student_group_assignment", "update_unit",
|
|
},
|
|
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
|
{
|
|
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
|
|
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
|
|
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
|
|
Assert.DoesNotContain(allNames, n =>
|
|
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
|
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
|
|
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetStudents_OhneFilter_LiefertNurAktiveSchueler()
|
|
{
|
|
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
|
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
|
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
|
|
|
var result = tool.GetStudents();
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal(active.Id, result[0].Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetStudents_IncludeInactive_LiefertAuchInaktive()
|
|
{
|
|
var active = new Student { FirstName = "Anna", LastName = "Aktiv", IsActive = true };
|
|
var inactive = new Student { FirstName = "Ida", LastName = "Inaktiv", IsActive = false };
|
|
var tool = new StudentTools(new FakeStudents([active, inactive]));
|
|
|
|
var result = tool.GetStudents(includeInactive: true);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
}
|
|
|
|
// ── ExamTools ────────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetExams_OhneIncludeResults_LiefertKeineErgebnisse()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
|
var results = new FakeResults();
|
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = Guid.NewGuid(), TotalPoints = 10 });
|
|
var tool = new ExamTools(new FakeExams([exam]), results);
|
|
|
|
var dto = Assert.Single(tool.GetExams(groupId));
|
|
|
|
Assert.Null(dto.Results);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetExams_MitIncludeResults_LiefertErgebnisseJeSchueler()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var studentId = Guid.NewGuid();
|
|
var exam = new Exam { GroupId = groupId, Title = "Klausur 1" };
|
|
var results = new FakeResults();
|
|
results.Add(new ExamResult { ExamId = exam.Id, StudentId = studentId, TotalPoints = 12.5, Grade = "2+" });
|
|
var tool = new ExamTools(new FakeExams([exam]), results);
|
|
|
|
var dto = Assert.Single(tool.GetExams(groupId, includeResults: true));
|
|
|
|
var result = Assert.Single(dto.Results!);
|
|
Assert.Equal(studentId, result.StudentId);
|
|
Assert.Equal("2+", result.Grade);
|
|
}
|
|
|
|
// ── GradeTools ───────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetGrades_MitStudentId_FiltertAufEinenSchueler()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var studentA = Guid.NewGuid();
|
|
var studentB = Guid.NewGuid();
|
|
var grades = new FakeGrades();
|
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
|
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
|
|
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
|
|
|
|
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
|
|
|
|
Assert.Equal(studentA, dto.StudentId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
|
|
{
|
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
|
var grades = new FakeGrades();
|
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
|
var groupId = Guid.NewGuid();
|
|
|
|
var result = await tool.CreateGradeEntry(
|
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.NotNull(result.Id);
|
|
Assert.Single(grades.GetByGroup(groupId));
|
|
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
|
|
Assert.Contains("Anna", confirmation.LastMessage);
|
|
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
|
|
{
|
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
|
var grades = new FakeGrades();
|
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
|
var groupId = Guid.NewGuid();
|
|
|
|
var result = await tool.CreateGradeEntry(
|
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
|
|
|
Assert.False(result.Applied);
|
|
Assert.Empty(grades.GetByGroup(groupId));
|
|
Assert.Equal(1, confirmation.CallCount);
|
|
}
|
|
|
|
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetSchedule_MitGroupId_FiltertNachGruppe()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var slots = new FakeTimetableSlots();
|
|
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
|
|
slots.Add(new TimetableSlot { GroupId = Guid.NewGuid(), Weekday = DayOfWeek.Tuesday, PeriodNumber = 2 });
|
|
var tool = new ScheduleTools(slots);
|
|
|
|
var dto = Assert.Single(tool.GetSchedule(groupId));
|
|
|
|
Assert.Equal(groupId, dto.GroupId);
|
|
}
|
|
|
|
// ── TimeEntryTools ───────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetTimeEntries_FiltertAufDenAngegebenenZeitraum()
|
|
{
|
|
var entries = new FakeTimeEntries();
|
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
|
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
|
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
|
|
|
|
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
|
|
|
Assert.Equal(30, dto.DurationMinutes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
|
|
{
|
|
var entries = new FakeTimeEntries();
|
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
|
|
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
|
|
{
|
|
var entries = new FakeTimeEntries();
|
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
|
|
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
|
|
|
Assert.False(result.Applied);
|
|
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
|
}
|
|
|
|
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
|
|
|
|
private static LessonPlanTools BuildLessonPlanTools(
|
|
FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null,
|
|
FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) =>
|
|
new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]),
|
|
attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation());
|
|
|
|
[Fact]
|
|
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var units = new FakeUnits();
|
|
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
|
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
|
|
var tool = BuildLessonPlanTools(units, lessons);
|
|
|
|
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
|
|
|
|
Assert.Single(result.Units);
|
|
var lesson = Assert.Single(result.Lessons);
|
|
Assert.Equal("Brechung", lesson.Topic);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit()
|
|
{
|
|
var group = new LearningGroup { Name = "7a" };
|
|
var units = new FakeUnits();
|
|
var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group]));
|
|
|
|
var result = await tool.CreateUnit(group.Id, "Optik");
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.Single(units.GetByGroup(group.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateUnit_OhneAenderung_FragtNichtNach()
|
|
{
|
|
var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned };
|
|
var units = new FakeUnits();
|
|
units.Add(unit);
|
|
var confirmation = new FakeMcpConfirmation();
|
|
var tool = BuildLessonPlanTools(units: units, confirmation: confirmation);
|
|
|
|
var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned);
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.Equal(0, confirmation.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage()
|
|
{
|
|
var confirmation = new FakeMcpConfirmation();
|
|
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
|
|
|
var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung");
|
|
|
|
Assert.False(result.Applied);
|
|
Assert.Equal(0, confirmation.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit()
|
|
{
|
|
var groupId = Guid.NewGuid();
|
|
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
|
var units = new FakeUnits();
|
|
units.Add(unit);
|
|
var lessons = new FakeLessons();
|
|
var tool = BuildLessonPlanTools(units, lessons);
|
|
|
|
var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung");
|
|
|
|
Assert.True(result.Applied);
|
|
var lesson = Assert.Single(lessons.GetByUnit(unit.Id));
|
|
Assert.Equal(groupId, lesson.GroupId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateLesson_AendertNurAngegebeneFelder()
|
|
{
|
|
var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" };
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
|
|
|
var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II");
|
|
|
|
Assert.True(result.Applied);
|
|
var updated = lessons.GetById(lesson.Id)!;
|
|
Assert.Equal("Brechung II", updated.Topic);
|
|
Assert.Equal("S. 12", updated.Homework);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn()
|
|
{
|
|
var lesson = new Lesson { Topic = "Brechung" };
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
|
|
|
var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
|
|
|
Assert.True(result.Applied);
|
|
var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases);
|
|
Assert.Equal("Einstieg", phase.Name);
|
|
Assert.Equal(result.Id, phase.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateLessonPhase_AendertNurAngegebeneFelder()
|
|
{
|
|
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" };
|
|
var lesson = new Lesson { Topic = "Brechung" };
|
|
lesson.Phases.Add(phase);
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
|
|
|
var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15);
|
|
|
|
Assert.True(result.Applied);
|
|
var updated = lessons.GetById(lesson.Id)!.Phases.Single();
|
|
Assert.Equal(15, updated.DurationMinutes);
|
|
Assert.Equal("Folie", updated.Material);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase()
|
|
{
|
|
var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 };
|
|
var lesson = new Lesson { Topic = "Brechung" };
|
|
lesson.Phases.Add(phase);
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
|
|
|
var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id);
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.Empty(lessons.GetById(lesson.Id)!.Phases);
|
|
}
|
|
|
|
[Fact]
|
|
public void DownloadLessonAttachment_LiefertBase64Inhalt()
|
|
{
|
|
var storage = new FakeAttachmentStorage();
|
|
var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray()));
|
|
var lesson = new Lesson { Topic = "Brechung" };
|
|
lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 });
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
|
|
|
|
var result = tool.DownloadLessonAttachment(lesson.Id, storageId);
|
|
|
|
Assert.Equal("blatt.pdf", result.FileName);
|
|
Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content)));
|
|
}
|
|
|
|
[Fact]
|
|
public void DownloadLessonAttachment_ZuGross_WirftFehler()
|
|
{
|
|
var lesson = new Lesson { Topic = "Brechung" };
|
|
lesson.Attachments.Add(new DocumentAttachment
|
|
{
|
|
StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1,
|
|
});
|
|
var lessons = new FakeLessons();
|
|
lessons.Add(lesson);
|
|
var tool = BuildLessonPlanTools(lessons: lessons);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => tool.DownloadLessonAttachment(lesson.Id, "big"));
|
|
}
|
|
|
|
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
|
|
{
|
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
|
var group = new LearningGroup { Name = "7a" };
|
|
var memberships = new FakeMemberships([]);
|
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
|
|
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
|
|
|
|
Assert.True(result.Applied);
|
|
var membership = Assert.Single(memberships.GetByStudent(student.Id));
|
|
Assert.Equal(Niveau.E, membership.Niveau);
|
|
Assert.Contains("Anna", confirmation.LastMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
|
|
{
|
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
|
var group = new LearningGroup { Name = "7a" };
|
|
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
|
|
var memberships = new FakeMemberships([existing]);
|
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
|
|
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
|
|
|
|
Assert.True(result.Applied);
|
|
Assert.Equal(0, confirmation.CallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
|
|
{
|
|
var group = new LearningGroup { Name = "7a" };
|
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
|
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
|
|
|
|
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
|
|
|
|
Assert.False(result.Applied);
|
|
Assert.Equal(0, confirmation.CallCount);
|
|
}
|
|
}
|