feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt zuerst einen menschenlesbaren Bestätigungsdialog (bestehender ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans. create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/ update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt (siehe TODO.md 4.5.26). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
@@ -594,3 +595,22 @@ public class FakeReportGrades : IReportGradeRepository
|
||||
}
|
||||
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
|
||||
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
|
||||
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
|
||||
public class FakeMcpConfirmation : IMcpConfirmationService
|
||||
{
|
||||
public bool Response { get; set; } = true;
|
||||
public string? LastTitle { get; private set; }
|
||||
public string? LastMessage { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||
{
|
||||
CallCount++;
|
||||
LastTitle = title;
|
||||
LastMessage = message;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,31 @@ public sealed class McpToolsTests
|
||||
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllowedReadTools_EnthaeltGenauDieFuenfPhase1Tools()
|
||||
public void AllowedReadTools_EnthaeltGenauDieSechsReadTools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "get_exams", "get_grades", "get_schedule", "get_students", "get_time_entries" },
|
||||
new[] { "get_exams", "get_grades", "get_lesson_plans", "get_schedule", "get_students", "get_time_entries" },
|
||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedReadTools_EnthaeltKeineDokumentationstypen()
|
||||
public void AllowedWriteTools_EnthaeltGenauDieDreiPhase2WriteTools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "create_grade_entry", "create_time_entry", "update_student_group_assignment" },
|
||||
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.
|
||||
Assert.DoesNotContain(McpToolScope.AllowedReadTools, n =>
|
||||
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
|
||||
Assert.DoesNotContain(allNames, n =>
|
||||
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase));
|
||||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
|
||||
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -98,13 +108,50 @@ public sealed class McpToolsTests
|
||||
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);
|
||||
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]
|
||||
@@ -129,10 +176,104 @@ public sealed class McpToolsTests
|
||||
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);
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[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 = new LessonPlanTools(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);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user