feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt). - Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt mit dem berechneten Vorschlag, aber frei aenderbar. - Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync: gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/ Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung. - Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/ apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert). - MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,72 @@ public sealed class AiPlanningServiceTests
|
||||
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUntisStatusSuggestionsAsync_PassendeIds_LiefertZuordnung()
|
||||
{
|
||||
const string body = """
|
||||
{"suggestions":[{"id":"0","status":"Excused"},{"id":"1","status":"Unexcused"}]}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||
var rows = new[]
|
||||
{
|
||||
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||
};
|
||||
|
||||
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||
|
||||
Assert.Equal("Excused", suggestions["0"]);
|
||||
Assert.Equal("Unexcused", suggestions["1"]);
|
||||
}
|
||||
|
||||
// Kernabsicherung gegen Verwechslung (Nutzer-Feedback zum Untis-Hub, siehe
|
||||
// AiPlanningService.RequestUntisStatusSuggestionsAsync): weicht die zurückgegebene Id-Menge
|
||||
// auch nur minimal von der gesendeten ab (hier: eine erfundene Id "2" statt "1"), wird die
|
||||
// gesamte Antwort verworfen statt sich auf eine möglicherweise vermischte Zuordnung zu verlassen.
|
||||
[Fact]
|
||||
public async Task RequestUntisStatusSuggestionsAsync_AbweichendeIdMenge_VerwirftKomplett()
|
||||
{
|
||||
const string body = """
|
||||
{"suggestions":[{"id":"0","status":"Excused"},{"id":"2","status":"Unexcused"}]}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||
var rows = new[]
|
||||
{
|
||||
new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" },
|
||||
new AiUntisStatusRow { Id = "1", CurrentGuess = "ExcusePending" },
|
||||
};
|
||||
|
||||
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||
|
||||
Assert.Empty(suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUntisStatusSuggestionsAsync_DoppelteId_VerwirftKomplett()
|
||||
{
|
||||
const string body = """
|
||||
{"suggestions":[{"id":"0","status":"Excused"},{"id":"0","status":"Unexcused"}]}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||
var rows = new[] { new AiUntisStatusRow { Id = "0", CurrentGuess = "ExcusePending" } };
|
||||
|
||||
var suggestions = await service.RequestUntisStatusSuggestionsAsync(rows, "token");
|
||||
|
||||
Assert.Empty(suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestUntisStatusSuggestionsAsync_KeineZeilen_KeinNetzwerkaufruf()
|
||||
{
|
||||
var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
var suggestions = await service.RequestUntisStatusSuggestionsAsync([], "token");
|
||||
|
||||
Assert.Empty(suggestions);
|
||||
}
|
||||
|
||||
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||
{
|
||||
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||
|
||||
@@ -623,14 +623,16 @@ public class FakeMcpConfirmation : IMcpConfirmationService
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public string? LastOperationKey { get; private set; }
|
||||
public bool? LastAllowSessionTrust { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "")
|
||||
[System.Runtime.CompilerServices.CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||
{
|
||||
CallCount++;
|
||||
LastTitle = title;
|
||||
LastMessage = message;
|
||||
LastOperationKey = operationKey;
|
||||
LastAllowSessionTrust = allowSessionTrust;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ public sealed class McpToolsTests
|
||||
new[]
|
||||
{
|
||||
"download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
|
||||
"get_lesson_plans", "get_schedule", "get_students", "get_subjects", "get_time_entries",
|
||||
"get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule", "get_students",
|
||||
"get_subjects", "get_time_entries", "get_untis_absence_rows", "get_untis_hub_status",
|
||||
"list_letter_templates", "render_letter",
|
||||
},
|
||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
@@ -29,8 +30,8 @@ public sealed class McpToolsTests
|
||||
new[]
|
||||
{
|
||||
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
|
||||
"create_competency_domain", "create_grade_entry", "create_lesson", "create_subject",
|
||||
"create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
|
||||
"apply_untis_absence_status", "create_competency_domain", "create_grade_entry", "create_lesson",
|
||||
"create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item",
|
||||
"remove_lesson_competency", "remove_lesson_phase", "update_competency_domain",
|
||||
"update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment",
|
||||
"update_subject", "update_unit",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Aus Konsistenz mit den drei bestehenden WebUntis-Abgleichs-ViewModels bewusst ohne Tests für
|
||||
/// GetUntisAbsenceRows/GetNamedUntisAbsencePattern gelassen (siehe TODO.md) - beide rufen
|
||||
/// WebUntisIntegrationService.GetLessonAbsencesAsync auf, was einen echten WebUntis-JSON-RPC-
|
||||
/// Handshake voraussetzt. Getestet werden die Teile, die keinen WebUntis-Zugriff brauchen:
|
||||
/// GetUntisHubStatus (reine Delegation an UntisHubService) und die Validierungspfade von
|
||||
/// ApplyUntisAbsenceStatus, die vor jedem WebUntis-/Datenbankzugriff greifen.
|
||||
public sealed class UntisComparisonToolsTests
|
||||
{
|
||||
private static UntisComparisonTools Build(
|
||||
List<LearningGroup>? groups = null, List<Student>? students = null,
|
||||
List<ParticipationSession>? sessions = null, FakeMcpConfirmation? confirmation = null) =>
|
||||
new(new FakeGroups(groups ?? []), new FakeStudents(students ?? []), new FakeSessions(sessions ?? []),
|
||||
new FakeEntries(), TestSupport.BuildWebUntisIntegrationService(),
|
||||
TestSupport.BuildUntisHubService(groups ?? []), confirmation ?? new FakeMcpConfirmation());
|
||||
|
||||
[Fact]
|
||||
public void GetUntisHubStatus_DelegiertAnUntisHubServiceUndMapptKindUndDueStateAlsText()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9a", WebUntisLessonId = 42 };
|
||||
var tool = Build([group]);
|
||||
|
||||
var rows = tool.GetUntisHubStatus();
|
||||
|
||||
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.FehlzeitenKurz) && r.GroupName == "9a"
|
||||
&& r.DueState == nameof(UntisHubDueState.Overdue));
|
||||
Assert.Contains(rows, r => r.Kind == nameof(UntisHubJobKind.OffenePeriods) && r.GroupName == "Offene Stunden");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyUntisAbsenceStatus_UnbekannterStatus_WirdOhneRowIdPruefungAbgelehnt()
|
||||
{
|
||||
var tool = Build();
|
||||
|
||||
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", "Suspendiert");
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Contains("Status", result.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyUntisAbsenceStatus_NichtSelektierbarerAberGueltigerEnumWert_WirdAbgelehnt()
|
||||
{
|
||||
// "Truant"/"Suspended" etc. sind gültige AttendanceStatus-Werte, aber keine, die WebUntis
|
||||
// hier je melden würde (siehe SelectableStatuses) - müssen trotzdem abgelehnt werden, damit
|
||||
// ein KI-Client nicht versehentlich einen fachlich unpassenden Status setzen kann.
|
||||
var tool = Build();
|
||||
|
||||
var result = await tool.ApplyUntisAbsenceStatus("irgendeine-id", nameof(AttendanceStatus.Truant));
|
||||
|
||||
Assert.False(result.Applied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyUntisAbsenceStatus_UnbekannteRowId_WirdAbgelehntOhneBestaetigung()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = Build(confirmation: confirmation);
|
||||
|
||||
var result = await tool.ApplyUntisAbsenceStatus("nie-vergeben", nameof(AttendanceStatus.Excused));
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Contains("row-id", result.Message);
|
||||
Assert.Equal(0, confirmation.CallCount); // erst gar nicht bis zur Bestätigung vorgedrungen
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -108,4 +109,34 @@ public sealed class UntisHubServiceTests
|
||||
|
||||
Assert.Equal(2, rows.Count(r => r.GroupId != null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRun_FehlzeitenLang_SchliesstAuchFehlzeitenKurzDerselbenGruppeAb()
|
||||
{
|
||||
// Nutzer-Feedback: der Langzeit-Abgleich deckt das kurzfristige Zeitfenster als Teilmenge mit
|
||||
// ab - ohne diese Kopplung bliebe die kurzfristige Kadenz trotz erledigtem Langzeit-Abgleich
|
||||
// als fällig stehen.
|
||||
var group = Group("9a", 42);
|
||||
var jobStates = new FakeUntisHubJobStates();
|
||||
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||
|
||||
hub.RecordRun(UntisHubJobKind.FehlzeitenLang, group.Id, "3 Fehlzeiten übernommen");
|
||||
|
||||
var kurzState = jobStates.Get(UntisHubJobKind.FehlzeitenKurz, group.Id);
|
||||
var langState = jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id);
|
||||
Assert.NotNull(kurzState?.LastRunAt);
|
||||
Assert.Equal(langState!.LastRunAt, kurzState!.LastRunAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordRun_FehlzeitenKurz_LaesstFehlzeitenLangUnangetastet()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var jobStates = new FakeUntisHubJobStates();
|
||||
var hub = new UntisHubService(new FakeGroups([group]), jobStates, new SchoolYearService());
|
||||
|
||||
hub.RecordRun(UntisHubJobKind.FehlzeitenKurz, group.Id, "keine Abweichungen");
|
||||
|
||||
Assert.Null(jobStates.Get(UntisHubJobKind.FehlzeitenLang, group.Id));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user