Compare commits
116
Commits
17475a781f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c128fc6741 | ||
|
|
4542f263fc | ||
|
|
86a97d91a7 | ||
|
|
763d7744be | ||
|
|
77fe9b1c79 | ||
|
|
92e497b54f | ||
|
|
861f2c76ca | ||
|
|
ef3e9dbbb6 | ||
|
|
03a5e0dd9c | ||
|
|
6dccf96039 | ||
|
|
6e57bf407d | ||
|
|
0b5cfc5522 | ||
|
|
09cbbf1b77 | ||
|
|
1671796844 | ||
|
|
70dc78904c | ||
|
|
7b660c7152 | ||
|
|
3675b70004 | ||
|
|
eb1b2340b7 | ||
|
|
b8193be6ec | ||
|
|
0962845ead | ||
|
|
42c650518c | ||
|
|
2c791258a1 | ||
|
|
c528bd825f | ||
|
|
56ab5067e6 | ||
|
|
bfe214ecf2 | ||
|
|
d979a84e7b | ||
|
|
3f8813df51 | ||
|
|
19aa302487 | ||
|
|
a4733c156b | ||
|
|
e6c30b0bc7 | ||
|
|
55fba2cadb | ||
|
|
dd2e1e7c61 | ||
|
|
0c60a54c4d | ||
|
|
9567d8d616 | ||
|
|
98f5573999 | ||
|
|
455c61c946 | ||
|
|
6af4bee1f0 | ||
|
|
c3ce1a7204 | ||
|
|
ba52109eaa | ||
|
|
bd0d7e47ea | ||
|
|
2ec8adac61 | ||
|
|
e86125e5f9 | ||
|
|
86e114580f | ||
|
|
835cccadec | ||
|
|
709ea88c2a | ||
|
|
73453fd88d | ||
|
|
265190b3aa | ||
|
|
fedfceb81d | ||
|
|
7ef0c95a73 | ||
|
|
94946ffefb | ||
|
|
3cccb3226c | ||
|
|
988a293c42 | ||
|
|
92f9a75ec1 | ||
|
|
0a419c4c11 | ||
|
|
216708e879 | ||
|
|
0d5fb6d146 | ||
|
|
1d9521fc60 | ||
|
|
4af83c3e68 | ||
|
|
15463bc292 | ||
|
|
987b49e761 | ||
|
|
fb7a015b42 | ||
|
|
09e97efb61 | ||
|
|
d297f9f109 | ||
|
|
de6d4236a2 | ||
|
|
edf7510765 | ||
|
|
2ccc3201d2 | ||
|
|
ab1feba0f0 | ||
|
|
b2d0ccd30d | ||
|
|
c6efdab9ef | ||
|
|
11ef73ab1f | ||
|
|
4b4a746e17 | ||
|
|
e2e2bfb854 | ||
|
|
3e5f197bdb | ||
|
|
95e3f677e4 | ||
|
|
cdbed69d8b | ||
|
|
527c186090 | ||
|
|
28469bf547 | ||
|
|
9dce6576e2 | ||
|
|
9f28081931 | ||
|
|
6468596bf3 | ||
|
|
23abe7af63 | ||
|
|
3f71f7f371 | ||
|
|
2f2761caf8 | ||
|
|
a088823b30 | ||
|
|
1215d4ac22 | ||
|
|
84c03962ef | ||
|
|
b7c7d46ee4 | ||
|
|
da4b88c93d | ||
|
|
538ad65a3a | ||
|
|
dade41ee8d | ||
|
|
2b299fc940 | ||
|
|
dbd8777e64 | ||
|
|
89a6376fb6 | ||
|
|
b7a105af73 | ||
|
|
7d3d021d09 | ||
|
|
7fbf035cbb | ||
|
|
9b04d7eb98 | ||
|
|
229ef79e75 | ||
|
|
cd08bbbbc4 | ||
|
|
442e3d5e3d | ||
|
|
3f484ce3c8 | ||
|
|
3f8dab4abd | ||
|
|
b3b86f0cf4 | ||
|
|
9d375caf14 | ||
|
|
cf8de08501 | ||
|
|
265ffe9a74 | ||
|
|
0c8bb58e1b | ||
|
|
69cc16bbd2 | ||
|
|
2b29dea824 | ||
|
|
056e864edd | ||
|
|
33c15ffe3e | ||
|
|
5e16ebf1bf | ||
|
|
7245196689 | ||
|
|
f9f5f75aa8 | ||
|
|
7fa8a93c82 | ||
|
|
87a7badb44 |
@@ -0,0 +1,24 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore LehrerApp.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build LehrerApp.sln --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test LehrerApp.sln --no-build --configuration Release --logger "console;verbosity=normal"
|
||||
Vendored
+21
-6
@@ -5,7 +5,7 @@
|
||||
"name": "Desktop App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"preLaunchTask": "TemplateDesigner bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
|
||||
@@ -15,6 +15,20 @@
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "TemplateDesigner App starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "Desktop bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole",
|
||||
"env": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "API Server starten",
|
||||
"type": "coreclr",
|
||||
@@ -33,11 +47,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Desktop + API (gleichzeitig)",
|
||||
"configurations": [
|
||||
"Desktop App starten",
|
||||
"API Server starten"
|
||||
]
|
||||
"name": "API Tests starten",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "API bauen",
|
||||
"program": "${workspaceFolder}/LehrerApp.Api.Tests/bin/Debug/net10.0/LehrerApp.Api.Tests.dll",
|
||||
"args": [],
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
|
||||
Vendored
+13
@@ -27,6 +27,19 @@
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "TemplateDesigner bauen",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj",
|
||||
"--configuration", "Debug",
|
||||
"--verbosity", "minimal"
|
||||
],
|
||||
"problemMatcher": "$msCompile",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Solution bauen",
|
||||
"command": "dotnet",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
|
||||
<NoWarn>CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -17,13 +17,17 @@
|
||||
<!-- Datenbank -->
|
||||
<PackageVersion Include="LiteDB" Version="5.0.21" />
|
||||
|
||||
<!-- PDF-Erzeugung (11.3) - nur LehrerApp.Desktop: zieht SkiaSharp-Native-Binaries mit,
|
||||
die im Server-Image (LehrerApp.Api) nichts verloren haben. -->
|
||||
<!-- PDF-Erzeugung: Desktop-Exporte und die unabhängige Templating-Library. -->
|
||||
<PackageVersion Include="QuestPDF" Version="2025.7.0" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.4.0" />
|
||||
<PackageVersion Include="PdfPig" Version="0.1.13" />
|
||||
|
||||
<!-- API -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
|
||||
<!-- MCP-Server (lokal, siehe TODO.md) -->
|
||||
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
|
||||
|
||||
<!-- Tests -->
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LiteDB;
|
||||
using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
|
||||
namespace LehrerApp.Api;
|
||||
@@ -67,7 +68,7 @@ public class EventStore(string dataPath) : IDisposable
|
||||
{
|
||||
var col = GetCol(userId);
|
||||
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
|
||||
.OrderBy(e => e.ServerSeq).Take(500)
|
||||
.OrderBy(e => e.ServerSeq).Take(SyncProtocol.PullBatchSize)
|
||||
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
|
||||
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
|
||||
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
|
||||
|
||||
@@ -76,6 +76,9 @@ public class AiLesson
|
||||
public List<AiPhaseStep> Phases { get; set; } = [];
|
||||
public string? Homework { get; set; }
|
||||
public string? Reflection { get; set; }
|
||||
/// Rohentwurf/erste Ideen der Lehrkraft vor der Feinplanung (siehe Lesson.PlanningIdeas) — reiner
|
||||
/// Kontext für die KI wie Homework/Reflection, wird symmetrisch übernommen/zurückgegeben.
|
||||
public string? PlanningIdeas { get; set; }
|
||||
}
|
||||
|
||||
public class AiPhaseStep
|
||||
@@ -100,6 +103,10 @@ public class AiPlanningResponse
|
||||
{
|
||||
public List<AiLesson> Lessons { get; set; } = [];
|
||||
public string? Summary { get; set; }
|
||||
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
|
||||
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
|
||||
// weder angezeigt noch gespeichert.
|
||||
public string? RawResponse { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -174,3 +181,54 @@ public class AiSubstanceResearchResponse
|
||||
public string ActivityRestriction { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-Vertrag für den Fehlzeiten-Statusvorschlag (ai-backend/untis-status.php, Nutzer-Feedback
|
||||
/// zum Untis-Hub: der aus WebUntis abgeleitete Zielstatus war über den Anzeigetext oft nicht
|
||||
/// nachvollziehbar). Bewusst OHNE jeden Personenbezug - <see cref="AiUntisStatusRow.Id"/> ist eine
|
||||
/// rein technische, für die KI bedeutungslose Kennung, über die der Desktop-Client die Antwort
|
||||
/// zurückordnet; kein Name, keine Klasse, kein Datum verlässt damit die App. Mehrere Zeilen eines
|
||||
/// Abgleichslaufs werden in einer Anfrage gebündelt statt je Zeile einzeln (Kosten/Latenz).
|
||||
/// </summary>
|
||||
public class AiUntisStatusRequest
|
||||
{
|
||||
public List<AiUntisStatusRow> Rows { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AiUntisStatusRow
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string ReasonText { get; set; } = "";
|
||||
public int AbsentMinutes { get; set; }
|
||||
/// True, wenn WebUntis für den Eintrag bereits ein Bearbeitungsdatum führt (unabhängig vom
|
||||
/// tatsächlichen Datum, das aus Datenschutzgründen nicht mitgeschickt wird).
|
||||
public bool HandledOn { get; set; }
|
||||
/// Schulinterne Konvention: Klammerung der Entschuldigungsnummer bedeutet unentschuldigt, ohne
|
||||
/// Klammern entschuldigt; null, wenn keine Nummer hinterlegt ist (siehe UntisDiffService-Analog
|
||||
/// in WebUntisLessonAbsenceComparisonViewModel.MapStatus).
|
||||
public bool? ExternKeyInParentheses { get; set; }
|
||||
/// Bereits regelbasiert ermittelter Status (siehe MapStatus) - der Systemprompt bittet die KI,
|
||||
/// nur bei eindeutigem Widerspruch im Freitext davon abzuweichen, statt bei Unsicherheit zu raten.
|
||||
public string CurrentGuess { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enthält absichtlich GENAU eine Zeile je gesendeter <see cref="AiUntisStatusRow.Id"/> - der
|
||||
/// Client verwirft die gesamte Antwort, wenn die zurückgegebene Id-Menge nicht exakt der
|
||||
/// gesendeten entspricht (siehe AiPlanningService.RequestUntisStatusSuggestionsAsync), statt sich
|
||||
/// auf die Reihenfolge zu verlassen. So bleibt eine Verwechslung zwischen Vorschlag und Zeile
|
||||
/// strukturell ausgeschlossen, nicht nur im Regelfall vermieden.
|
||||
/// </summary>
|
||||
public class AiUntisStatusResponse
|
||||
{
|
||||
public List<AiUntisStatusSuggestion> Suggestions { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AiUntisStatusSuggestion
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
/// Einer von "Present"/"Late"/"LeftDuringClass"/"ExcusePending"/"Excused"/"Unexcused" (siehe
|
||||
/// Systemprompt in ai-backend/untis-status.php) - wird client-seitig gegen genau diese Menge
|
||||
/// geprüft, bevor er als AttendanceStatus interpretiert wird.
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ public interface IUnitRepository
|
||||
}
|
||||
public interface ILessonRepository
|
||||
{
|
||||
Lesson? GetById(Guid id);
|
||||
List<Lesson> GetByUnit(Guid unitId);
|
||||
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
|
||||
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
|
||||
@@ -136,6 +137,48 @@ public interface IUntisSnapshotRepository
|
||||
void Save(UntisSnapshotEntry entry);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
/// Lokaler Cache der Fehlzeiten-Berichtszeilen fürs Klassenlehrer-Feature (siehe TODO.md) —
|
||||
/// bewusst nicht synchronisiert (siehe Implementierung), jedes Gerät ruft WebUntis selbst ab.
|
||||
public interface IUntisAbsenceCacheRepository
|
||||
{
|
||||
List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
/// Löscht vorhandene Zeilen im Bereich und fügt die übergebenen neu ein — fürs "heiße" Fenster,
|
||||
/// dessen Inhalt sich seit dem letzten Abruf geändert haben kann.
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
/// Fügt nur ein, ohne zu löschen — fürs Erweitern der als endgültig angenommenen "kalten" Historie.
|
||||
void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der Klassenbuch-Berichtszeilen anderer Lehrkräfte fürs Klassenlehrer-Feature —
|
||||
/// gleiches Muster wie <see cref="IUntisAbsenceCacheRepository"/>.
|
||||
public interface IUntisClassRegisterCacheRepository
|
||||
{
|
||||
List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate);
|
||||
void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries);
|
||||
}
|
||||
/// Lokaler Cache der aktuellen Klassenliste (Roster) fürs Klassenlehrer-Feature — anders als
|
||||
/// Fehlzeiten/Klassenbuch keine Historie, deshalb kein ReplaceRange/InsertRange, sondern immer der
|
||||
/// komplette Ersatz des zuletzt bekannten Standes einer Klasse.
|
||||
public interface IUntisStudentRosterCacheRepository
|
||||
{
|
||||
List<UntisStudentRosterCacheEntry> GetByClass(string className);
|
||||
void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries);
|
||||
}
|
||||
/// Merkt sich je Klasse+Berichtsart, wann das heiße Fenster zuletzt aufgefrischt wurde und wie weit
|
||||
/// die kalte Historie bereits abgedeckt ist (siehe UntisReportCacheService).
|
||||
public interface IUntisCacheFetchStateRepository
|
||||
{
|
||||
UntisCacheFetchState? Get(string className, UntisCacheKind kind);
|
||||
void Save(UntisCacheFetchState state);
|
||||
}
|
||||
/// Zuletzt-Lauf-Status der Untis-Hub-Jobs (siehe TODO.md) - je (Kind, GroupId), GroupId null bei
|
||||
/// den dashboard-weiten Jobs.
|
||||
public interface IUntisHubJobStateRepository
|
||||
{
|
||||
UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId);
|
||||
List<UntisHubJobState> GetAll();
|
||||
void Save(UntisHubJobState state);
|
||||
}
|
||||
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
|
||||
public interface IUntisSlotMappingRepository
|
||||
{
|
||||
@@ -166,6 +209,15 @@ public interface IDocumentationRepository
|
||||
/// Entfernt den Eintrag endgültig — nur für die Löschfristen-Bereinigung (5.4.2).
|
||||
void HardDelete(Guid id);
|
||||
}
|
||||
public interface IVorgangRepository
|
||||
{
|
||||
List<Vorgang> GetAll();
|
||||
List<Vorgang> GetByStudent(Guid studentId);
|
||||
Vorgang? GetById(Guid id);
|
||||
void Save(Vorgang vorgang);
|
||||
/// Markiert den Vorgang als gelöscht, statt ihn hart zu entfernen (gleiches Muster wie Documentation).
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IWorkTaskRepository
|
||||
{
|
||||
List<WorkTask> GetByStatus(WorkTaskStatus status);
|
||||
@@ -191,6 +243,9 @@ public interface IParticipationSessionRepository
|
||||
{
|
||||
List<ParticipationSession> GetByGroup(Guid groupId);
|
||||
ParticipationSession? GetById(Guid id);
|
||||
// Für Aggregationen über alle Gruppen (z.B. Dashboard-Fehlzeiten-Warnung), die sonst pro
|
||||
// Eintrag einzeln GetById aufrufen müssten — ein Bulk-Laden vermeidet dieses N+1-Muster.
|
||||
List<ParticipationSession> GetAll();
|
||||
void Save(ParticipationSession session);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
@@ -230,6 +285,10 @@ public interface ISubjectRepository
|
||||
public interface ICompetencyDomainRepository
|
||||
{
|
||||
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel);
|
||||
// Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog
|
||||
// eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der
|
||||
// Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste.
|
||||
List<CompetencyDomain> GetBySubject(Guid subjectId);
|
||||
CompetencyDomain? GetById(Guid id);
|
||||
void Save(CompetencyDomain domain);
|
||||
void Delete(Guid id);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace LehrerApp.Core.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Named-Pipe-Konvention zwischen dem Avalonia-Hauptprozess (Pipe-Server, siehe
|
||||
/// LehrerApp.Desktop/Services/Mcp) und LehrerApp.McpBridge (Pipe-Client). Bewusst als einzelne
|
||||
/// geteilte Konstante statt eigener Bibliothek — die Bridge reicht JSON-RPC-Nachrichten unverändert
|
||||
/// durch und braucht sonst keine gemeinsamen Typen mit der App (siehe Planungsdokument).
|
||||
/// </summary>
|
||||
public static class McpPipeConstants
|
||||
{
|
||||
public const string PipeName = "LehrerApp.Mcp";
|
||||
|
||||
/// <summary>
|
||||
/// Unter macOS/Linux emuliert .NET Named Pipes über eine Socket-Datei unter
|
||||
/// <see cref="Path.GetTempPath"/> (intern "CoreFxPipe_" + Name), und die liest dafür die
|
||||
/// TMPDIR-Umgebungsvariable des jeweiligen Prozesses. LehrerApp.Desktop (von Finder/Dock
|
||||
/// gestartet) bekommt das reguläre per-Login-Session-TMPDIR unter /var/folders/.../T,
|
||||
/// während LehrerApp.McpBridge als Kindprozess von Claude Desktop oft eine reduzierte
|
||||
/// Umgebung mit TMPDIR=/tmp erbt - beide Prozesse suchen die Pipe-Datei dann an
|
||||
/// unterschiedlichen Orten und finden sich nie ("MCP nicht erreichbar, obwohl LehrerApp
|
||||
/// läuft", auch wenn beide Prozesse laufen und die Pipe grundsätzlich offen ist). Fix: TMPDIR
|
||||
/// für beide Prozesse hart auf denselben Ordner setzen, bevor die erste
|
||||
/// NamedPipeServerStream/-ClientStream-Instanz entsteht - dieselbe ApplicationData-Basis wie
|
||||
/// AppBootstrapper (siehe dort) wird anders als TMPDIR zuverlässig an Kindprozesse
|
||||
/// weitergereicht. Muss als eine der ersten Anweisungen in Main aufgerufen werden (Desktop wie
|
||||
/// Bridge), auf Windows ein No-op (dort nutzen Named Pipes den Kernel-Namespace, keine
|
||||
/// Socket-Datei).
|
||||
/// </summary>
|
||||
public static void EnsureStableUnixSocketDirectory()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"LehrerApp", "ipc");
|
||||
Directory.CreateDirectory(dir);
|
||||
Environment.SetEnvironmentVariable("TMPDIR", dir);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ public class Exam
|
||||
public DateOnly? ReturnedAt { get; set; }
|
||||
public Niveau? Niveau { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateOnly? ApprovalGrantedAt { get; set; }
|
||||
public DateOnly? AnnouncedAt { get; set; }
|
||||
/// Verknüpft Parallelklausuren (gleiche Arbeit, mehrere Kurse) für den Kurs-Umschalter
|
||||
/// auf der Klausuren-Hauptseite — Id der "Ursprungs"-Klausur, gesetzt beim Duplizieren
|
||||
/// (siehe ExamDialogViewModel.Save).
|
||||
public Guid? SharedExamGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,27 @@ public class LearningGroup
|
||||
/// deaktivierbar - blendet die Erinnerung "Ungeplante Stunden" im Dashboard für diese Gruppe aus.
|
||||
/// </summary>
|
||||
public bool RequiresLessonPlanning { get; set; } = true;
|
||||
/// <summary>
|
||||
/// WebUntis-interne Unterrichtsnummer (lsid) dieser Lerngruppe, Grundlage für den Abruf des
|
||||
/// "Fehlzeiten pro Unterricht"-Berichts. Wird von WebUntis pro Schuljahr neu vergeben und muss
|
||||
/// deshalb händisch je Lerngruppe/Schuljahr gepflegt werden - keine JSON-RPC-Methode liefert sie.
|
||||
/// </summary>
|
||||
public int? WebUntisLessonId { get; set; }
|
||||
/// <summary>
|
||||
/// Blendet diese Lerngruppe im UntisHub (siehe <see cref="Services"/>-Layer im Desktop-Projekt:
|
||||
/// UntisHubService) aus, auch wenn <see cref="WebUntisLessonId"/> gesetzt ist - z.B. für
|
||||
/// Klassenrat/AGs, die zwar eine WebUntis-Unterrichtsnummer haben, aber nicht auf
|
||||
/// Fehlzeiten/offene Periods hin überwacht werden sollen. Default false (alte Datensätze
|
||||
/// verhalten sich unverändert wie bisher).
|
||||
/// </summary>
|
||||
public bool ExcludedFromUntisHub { get; set; }
|
||||
/// <summary>
|
||||
/// Vorgängergruppe aus dem Hochstufen (<see cref="Services.GroupRolloverService"/>) —
|
||||
/// ermöglicht einen Schuljahresvergleich (z.B. Klausurschnitt) über die Kette hinweg.
|
||||
/// Wird ausschließlich beim Hochstufen automatisch gesetzt; vor Einführung dieses Felds
|
||||
/// hochgestufte Gruppen bleiben unverknüpft (kein rückwirkendes Verknüpfen vorgesehen).
|
||||
/// </summary>
|
||||
public Guid? PreviousGroupId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,26 @@ public class ParticipationEntry
|
||||
// Bleibt für bereits gespeicherte Daten erhalten. Neue Schreibvorgänge setzen beide Felder.
|
||||
public bool HomeworkMissing { get; set; }
|
||||
public AttendanceStatus? Attendance { get; set; }
|
||||
// Strichliste im Sitzplan (Nutzer-Feedback): RaisedHandCount zählt jede beobachtete Meldung,
|
||||
// CalledOnCount die Teilmenge davon, bei der der Schüler auch drangekommen ist —
|
||||
// "drangekommen" ist immer auch eine Meldung und erhöht daher immer beide Zähler zugleich,
|
||||
// nie CalledOnCount allein. Ergibt nebenbei eine Aufrufgerechtigkeits-Quote
|
||||
// (CalledOnCount / RaisedHandCount) und schlägt die Quantitätsbewertung vor, siehe
|
||||
// ParticipationCountSuggestion.
|
||||
public int RaisedHandCount { get; set; }
|
||||
public int CalledOnCount { get; set; }
|
||||
/// <summary>
|
||||
/// Tagesflagge (Nutzer-Feedback): markiert eine session-bezogene Bewertung als besonders
|
||||
/// herausragend oder besonders schwach, unabhängig von der Richtung ("egal in welche
|
||||
/// Richtung, herausragend festhalten"). Rein deskriptiv — fließt nirgends in eine Berechnung
|
||||
/// (Mitarbeitsnote 3.2, Aufrufgerechtigkeit) ein, nur zur Erinnerung/Dokumentation.
|
||||
/// </summary>
|
||||
public DayHighlightKind? DayHighlight { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public enum DayHighlightKind { Standout, Sleepy, Rough }
|
||||
|
||||
/// <summary>
|
||||
/// Hausaufgabenstatus einer Sitzung; null bedeutet, dass für diesen Termin keine
|
||||
/// Hausaufgabe erfasst wurde. MissingOpen kann später in MissingOverdue oder SubmittedLate
|
||||
@@ -160,3 +177,27 @@ public static class ParticipationRatingScale
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nutzer-Feedback: die Strichliste im Sitzplan (<see cref="ParticipationEntry.RaisedHandCount"/>)
|
||||
/// schlägt direkt eine Quantitätsbewertung vor — wie oft sich jemand meldet, ist ja bereits die
|
||||
/// Quantität. Nur ein Richtwert, kein fester Automatismus: die Lehrkraft übernimmt den Vorschlag
|
||||
/// per Klick oder ignoriert ihn. Bewusst nur für <see cref="AspectValueType.Scale5"/> (der
|
||||
/// Standardtyp des "quantity"-Aspekts) — für individuell umkonfigurierte Aspekttypen gäbe es keine
|
||||
/// sinnvolle, unmissverständliche Abbildung.
|
||||
/// </summary>
|
||||
public static class ParticipationCountSuggestion
|
||||
{
|
||||
public static int? SuggestQuantity(AspectValueType type, int raisedHandCount)
|
||||
{
|
||||
if (type != AspectValueType.Scale5) return null;
|
||||
return raisedHandCount switch
|
||||
{
|
||||
0 => -2,
|
||||
1 => -1,
|
||||
2 or 3 => 0,
|
||||
4 or 5 => 1,
|
||||
_ => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,15 @@ public class Lesson : IHasAttachments
|
||||
/// Optionaler Stundenbeginn — solange kein Stundenplan (Kapitel 4.3) existiert, manuell
|
||||
/// gepflegt. Dient nur der abgeleiteten Uhrzeit-Anzeige je Phase in <see cref="Phases"/>.
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
/// Grobe Ideen/erster Entwurf, festgehalten lange bevor der Verlaufsplan (<see cref="Phases"/>)
|
||||
/// feingeplant wird (Nutzer-Feedback: die eigentliche Ideenfindung liegt zeitlich oft weit vor
|
||||
/// der Feinplanung) — bewusst ein eigenes Feld statt Zweckentfremdung von <see cref="Reflection"/>
|
||||
/// (die ist nach der Stunde) oder <see cref="Unit.Notes"/> (die ist auf Einheitenebene, nicht je
|
||||
/// Stunde). Wird auch der KI-Planungsunterstützung (4.5.9) als Kontext mitgegeben, damit erste
|
||||
/// eigene Ideen bei der KI-gestützten Weiterplanung nicht erneut abgetippt werden müssen.
|
||||
public string? PlanningIdeas { get; set; }
|
||||
public List<LessonPhaseStep> Phases { get; set; } = [];
|
||||
public TeachingTimelineState? TeachingTimeline { get; set; }
|
||||
public string? Homework { get; set; }
|
||||
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
|
||||
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
|
||||
@@ -63,6 +71,11 @@ public class Lesson : IHasAttachments
|
||||
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
|
||||
public bool HomeworkCheckDismissed { get; set; }
|
||||
public string? Reflection { get; set; }
|
||||
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
|
||||
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
|
||||
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
|
||||
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
|
||||
public List<string> Competencies { get; set; } = [];
|
||||
public LessonStatus Status { get; set; } = LessonStatus.Planned;
|
||||
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
|
||||
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
|
||||
@@ -89,6 +102,14 @@ public class LessonPhaseStep
|
||||
public string Activity { get; set; } = "";
|
||||
public string Material { get; set; } = "";
|
||||
public string Shorthand { get; set; } = "";
|
||||
/// Der über <see cref="AiPlanning.AiPlanningService.BuildMaterialPrompt"/> erzeugte, vollständige
|
||||
/// Prompt für diese Phase (4.5.20), sofern die KI beim letzten "Übernehmen" einen Medienvorschlag
|
||||
/// gemacht hatte — anders als der reine Vorschlagstext (<c>AiPhaseStep.MaterialSuggestion</c>,
|
||||
/// nur transient während der Review) bewusst hier persistiert (Nutzer-Feedback: der Prompt soll
|
||||
/// auch nach dem Übernehmen noch abrufbar/erneut kopierbar bleiben, statt nur einmalig im
|
||||
/// Review-Dialog verfügbar zu sein). Rein informativ, kein Datenmodell-Bezug zu einem separaten
|
||||
/// "Material"-Konzept, das es weiterhin nicht gibt — siehe TODO.md 4.5.26.
|
||||
public string? MaterialPrompt { get; set; }
|
||||
/// <summary>
|
||||
/// null = Hauptweg. Sonst Verweis auf einen benannten <see cref="AlternativeLessonPath"/> aus
|
||||
/// dem Katalog, über den Phasen alternativer Unterrichtsverläufe zusammengehören —
|
||||
@@ -100,7 +121,10 @@ public class LessonPhaseStep
|
||||
}
|
||||
|
||||
public enum UnitStatus { Planned, Active, Completed }
|
||||
public enum LessonStatus { Planned, Conducted }
|
||||
// Planned=0 und Conducted=1 bleiben absichtlich an ihren bisherigen numerischen Positionen:
|
||||
// LiteDB hat diese Werte bereits gespeichert. Die neuen Zustände werden nur angehängt, damit
|
||||
// vorhandene Daten ohne Migration weiterhin korrekt gelesen werden.
|
||||
public enum LessonStatus { Planned = 0, Conducted = 1, Draft = 2, Ready = 3, Cancelled = 4 }
|
||||
|
||||
/// <summary>
|
||||
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
|
||||
@@ -236,3 +260,21 @@ public class ReportGrade
|
||||
public bool IsLocked { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Live timing is separate from the original lesson plan.</summary>
|
||||
public class TeachingTimelineState
|
||||
{
|
||||
public DateTime StartUtc { get; set; }
|
||||
public DateTime EndUtc { get; set; }
|
||||
public DateTime? HeldSinceUtc { get; set; }
|
||||
public Guid? HeldPhaseId { get; set; }
|
||||
public List<TeachingPhaseTiming> Phases { get; set; } = [];
|
||||
public List<Guid> TransferredPhaseIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class TeachingPhaseTiming
|
||||
{
|
||||
public Guid PhaseId { get; set; }
|
||||
public double Minutes { get; set; }
|
||||
public bool ExplicitlyStarted { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Lokal zwischengespeicherte Zeile aus dem WebUntis-Bericht "Fehlzeiten pro Schüler*in"
|
||||
/// (Klassenlehrer-Feature, siehe TODO.md) — 1:1-Abbildung von `UntisClassAbsenceEntryDto`
|
||||
/// (LehrerApp.Desktop), damit Fehlzeiten nicht bei jedem Öffnen der Ansicht neu abgerufen werden
|
||||
/// müssen. <c>Date</c> bewusst als <c>int</c> (yyyyMMdd) wie die DTOs statt <c>DateOnly</c>/
|
||||
/// <c>DateTime</c> — vermeidet die LiteDB-<c>DateTime</c>-Kind-Tücke (siehe CLAUDE.md) und ist
|
||||
/// direkt für Bereichs-Queries nutzbar.
|
||||
/// </summary>
|
||||
public class UntisAbsenceCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public string StudentName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public int Date { get; set; }
|
||||
public int AbsentPeriods { get; set; }
|
||||
public int AbsentMinutes { get; set; }
|
||||
public string? TeacherUsernames { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string? AbsenceReason { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public int? EntryId { get; set; }
|
||||
public string? HandledOn { get; set; }
|
||||
public bool Counts { get; set; }
|
||||
public string? ExcuseNote { get; set; }
|
||||
public int? PeriodNumber { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool CountsAsFullDay { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem "-alle-"-Klassenbuchbericht, gefiltert auf
|
||||
/// Einträge anderer Lehrkräfte (Klassenlehrer-Feature) — 1:1-Abbildung von
|
||||
/// `UntisForeignClassRegisterEventDto` (LehrerApp.Desktop).</summary>
|
||||
public class UntisClassRegisterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int Date { get; set; }
|
||||
public string? Subject { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Lokal zwischengespeicherte Zeile aus dem WebUntis-Schülerreport, eingeschränkt auf die
|
||||
/// für die Klassenlehrer-Übersicht nötigen Felder (Roster-Namen) — anders als Fehlzeiten/
|
||||
/// Klassenbuch keine Historie, sondern immer nur der zuletzt abgerufene Stand einer Klasse
|
||||
/// (siehe <see cref="UntisClassRegisterCacheEntry"/> für den Grund, warum es trotzdem ein eigenes
|
||||
/// Modell statt Wiederverwendung des vollen Schülerreport-DTOs ist: nur diese zwei Felder werden
|
||||
/// für den Roster-Abgleich tatsächlich gebraucht).</summary>
|
||||
public class UntisStudentRosterCacheEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public int? ExternKey { get; set; }
|
||||
public string DisplayName { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum UntisCacheKind { Absences, ClassRegister, Roster }
|
||||
|
||||
/// <summary>
|
||||
/// Ein Datensatz pro (<see cref="ClassName"/>, <see cref="Kind"/>): wann das "heiße" Fenster
|
||||
/// (die letzten Tage, deren Status sich noch ändern kann) zuletzt aufgefrischt wurde, und wie weit
|
||||
/// die "kalte", als endgültig angenommene Historie bereits lückenlos zurückreicht. Getrennt von den
|
||||
/// Cache-Zeilen selbst, weil ein Fenster ohne Treffer (z.B. eine ereignislose Woche) sonst keine
|
||||
/// Zeile hinterließe, aus der sich "zuletzt abgerufen am…" ableiten ließe.
|
||||
/// </summary>
|
||||
public class UntisCacheFetchState
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string ClassName { get; set; } = "";
|
||||
public UntisCacheKind Kind { get; set; }
|
||||
public DateTime? HotWindowFetchedAt { get; set; }
|
||||
public int? ColdCoverageStartDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
public enum UntisHubJobKind
|
||||
{
|
||||
FehlzeitenKurz,
|
||||
FehlzeitenLang,
|
||||
OffenePeriods,
|
||||
Klassenbuchabgleich,
|
||||
Hausaufgabenabgleich,
|
||||
}
|
||||
|
||||
/// <summary>Wann welcher WebUntis-Abgleich ("Untis-Hub"-Job, siehe TODO.md) zuletzt lief und mit
|
||||
/// welchem Ergebnis - ein Datensatz pro (<see cref="Kind"/>, <see cref="GroupId"/>). Bei den drei
|
||||
/// dashboard-weiten Jobs (<see cref="UntisHubJobKind.OffenePeriods"/>,
|
||||
/// <see cref="UntisHubJobKind.Klassenbuchabgleich"/>, <see cref="UntisHubJobKind.Hausaufgabenabgleich"/>)
|
||||
/// ist <see cref="GroupId"/> null; die beiden Fehlzeiten-Kadenzen sind je Lerngruppe getrennt.</summary>
|
||||
public class UntisHubJobState
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public UntisHubJobKind Kind { get; set; }
|
||||
public Guid? GroupId { get; set; }
|
||||
public DateTime? LastRunAt { get; set; }
|
||||
public string? LastResultSummary { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Fallmappe für ein konkretes, laufendes Problem mit einer/einem oder mehreren Schüler*innen
|
||||
/// (z.B. Schuleschwänzen, wiederholte Hausaufgaben-Verweigerung, ein Konflikt) — bündelt Titel,
|
||||
/// Problembeschreibung, Schlagwörter sowie die dazugehörigen <see cref="Documentation"/>-Einträge
|
||||
/// und eingefrorene WebUntis-Klassenbucheinträge an einem Ort, statt sie über datumsgefilterte
|
||||
/// Listen verstreut zu lassen. Bewusst nur Referenzen (<see cref="DocumentationIds"/>) statt
|
||||
/// gecachtem Inhalt — die Datenmenge pro Lehrkraft ist klein genug, dass ein Cache nur
|
||||
/// Invalidierungs-Komplexität einführen würde, ohne echten Nutzen (siehe TODO.md, Präzedenzfall
|
||||
/// 5.3.3: eine Plan-ID-Gruppierung für Förderpläne wurde aus demselben Grund verworfen).
|
||||
/// </summary>
|
||||
public class Vorgang
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public List<Guid> StudentIds { get; set; } = [];
|
||||
/// Freie Labels, z.B. "Absentismus", "Hausaufgaben", "Verspätungen", "Konflikte" —
|
||||
/// siehe VorgangTagDisplay.Suggestions im Desktop-Projekt für die Vorschlagsliste.
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public VorgangStatus Status { get; set; } = VorgangStatus.Open;
|
||||
public List<Guid> DocumentationIds { get; set; } = [];
|
||||
public List<VorgangClassRegisterEntry> ClassRegisterEntries { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ClosedAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum VorgangStatus { Open, Closed }
|
||||
|
||||
/// <summary>
|
||||
/// Eingefrorene Kopie einer WebUntis-Klassenbuchzeile zum Zeitpunkt des Anheftens an einen
|
||||
/// <see cref="Vorgang"/> — keine Referenz per ID, da weder <c>UntisForeignClassRegisterEventDto</c>
|
||||
/// noch <c>UntisClassRegisterCacheEntry</c> eine über einen Cache-Refresh hinweg stabile ID haben.
|
||||
/// </summary>
|
||||
public class VorgangClassRegisterEntry
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public string StudentName { get; set; } = "";
|
||||
public string? Subject { get; set; }
|
||||
public string? TeacherUsername { get; set; }
|
||||
public string? CategoryName { get; set; }
|
||||
public string? CategoryGroup { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime PinnedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -24,6 +24,10 @@ public class Documentation : IHasAttachments
|
||||
/// <summary>Im Unterricht schnell erfasst und noch inhaltlich nachzuarbeiten.</summary>
|
||||
public bool IsDraft { get; set; }
|
||||
public bool IsConfidential { get; set; }
|
||||
/// <summary>Für die pädagogische Arbeit/Leistungsbewertung gedacht, aber nicht für den
|
||||
/// WebUntis-Klassenbuch-Abgleich vorgesehen - blendet den Eintrag im Abgleichsdialog
|
||||
/// (WebUntisDocumentationComparisonViewModel) aus dem Zwischenablage-Vorschlag aus.</summary>
|
||||
public bool ExcludeFromWebUntisSync { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
// Löschen entfernt den Eintrag nicht hart, sondern markiert ihn nur (Nachvollziehbarkeit, 5.1.4).
|
||||
@@ -89,7 +93,7 @@ public interface IHasAttachments
|
||||
List<DocumentAttachment> Attachments { get; }
|
||||
}
|
||||
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter }
|
||||
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence, ParentCall, ParentLetter, Planning }
|
||||
public enum SupportStatus { Active, Completed, Paused }
|
||||
|
||||
public class WorkTask
|
||||
|
||||
@@ -14,6 +14,13 @@ public class BackupService
|
||||
|
||||
public string BackupDirectory => _backupDirectory;
|
||||
|
||||
/// Optionales zweites Sicherungsziel (z.B. USB-Stick, Netzlaufwerk) — Backups liegen sonst
|
||||
/// ausschließlich neben der Live-Datenbank und sind bei einem Plattenausfall oder einem
|
||||
/// versehentlich gelöschten Profilverzeichnis wertlos. Wird von außen gesetzt (Desktop-Settings,
|
||||
/// siehe BackupSettingsService); bewusst best-effort — ist das Ziel beim Sichern nicht
|
||||
/// erreichbar (Stick nicht eingesteckt, Freigabe offline), bleibt nur das Hauptbackup bestehen.
|
||||
public string? SecondaryBackupDirectory { get; set; }
|
||||
|
||||
public BackupService(string appDataPath)
|
||||
{
|
||||
_backupDirectory = Path.Combine(appDataPath, "backups");
|
||||
@@ -34,9 +41,36 @@ public class BackupService
|
||||
File.Copy(databasePath, target, overwrite: true);
|
||||
|
||||
PruneOldBackups(keepCount);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SecondaryBackupDirectory))
|
||||
TryMirrorToSecondary(target, fileName, keepCount);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private void TryMirrorToSecondary(string sourcePath, string fileName, int keepCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SecondaryBackupDirectory!);
|
||||
File.Copy(sourcePath, Path.Combine(SecondaryBackupDirectory!, fileName), overwrite: true);
|
||||
|
||||
foreach (var stale in Directory.GetFiles(SecondaryBackupDirectory!, "lehrerapp-*.db")
|
||||
.Select(p => new FileInfo(p))
|
||||
.OrderByDescending(f => f.LastWriteTime)
|
||||
.Skip(keepCount))
|
||||
{
|
||||
try { stale.Delete(); }
|
||||
catch { /* nächster Lauf versucht es erneut */ }
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Sicherungsziel nicht erreichbar — das Hauptbackup (_backupDirectory) ist davon
|
||||
// unberührt, ein manuelles/automatisches Backup soll daran nicht scheitern.
|
||||
}
|
||||
}
|
||||
|
||||
public List<BackupInfo> ListBackups() =>
|
||||
Directory.GetFiles(_backupDirectory, "lehrerapp-*.db")
|
||||
.Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length))
|
||||
|
||||
@@ -12,10 +12,13 @@ public sealed class DashboardCardSetting
|
||||
/// <summary>Speichert Sichtbarkeit und Reihenfolge der Dashboard-Kacheln lokal.</summary>
|
||||
public sealed class DashboardSettingsService
|
||||
{
|
||||
// "attention" fasst die frueheren sieben Kacheln missingteachingtime/excuses/corrections/
|
||||
// unplanned/alerts/attendance/support zusammen (siehe AttentionItem.cs im Desktop-Projekt).
|
||||
// Alte gespeicherte dashboardsettings.json-Dateien mit den frueheren Keys sind unproblematisch:
|
||||
// Load() unten verwirft unbekannte Keys ohnehin stillschweigend und ergaenzt neue als sichtbar.
|
||||
public static readonly string[] DefaultCardOrder =
|
||||
[
|
||||
"today", "tasks", "calendar", "excuses", "upcoming",
|
||||
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
|
||||
"today", "tasks", "attention", "calendar", "upcoming", "groups", "examload",
|
||||
];
|
||||
|
||||
private readonly string _configPath;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Zählt den Korrekturfortschritt einer Klausur aus den vorhandenen Ergebnissen, statt einen
|
||||
/// eigenen "Korrektur läuft"-Status pflegen zu müssen — ein `ExamResult` gilt als erledigt, sobald
|
||||
/// entweder Punkte/Note eingetragen oder der Schüler als abwesend markiert wurde. Von
|
||||
/// DashboardViewModel (Karte "Offene Korrekturen") und ExamsOverviewViewModel (Klausuren-
|
||||
/// Hauptseite) gemeinsam genutzt, damit beide Stellen exakt dieselbe Zahl zeigen.
|
||||
public static class ExamCorrectionCounter
|
||||
{
|
||||
public static (int Expected, int Evaluated) Count(Exam exam, List<GroupMembership> groupMemberships,
|
||||
List<ExamResult> examResults)
|
||||
{
|
||||
var expected = groupMemberships.Count(m => GroupMembershipService.IsActiveOn(m, exam.Date));
|
||||
var evaluated = examResults.Count(r => r.Absent || !string.IsNullOrWhiteSpace(r.Grade) || r.Points.Count > 0);
|
||||
return (expected, Math.Min(evaluated, expected));
|
||||
}
|
||||
}
|
||||
|
||||
public enum ExamListStatus { Planned, AwaitingCorrection, CorrectionInProgress, CorrectionStuck, AwaitingReturn, Returned }
|
||||
|
||||
public readonly record struct ExamPriorityInfo(ExamListStatus Status, double Score);
|
||||
|
||||
/// Ordnet Klausuren für die Klausuren-Hauptseite nach einem unsichtbaren Prioritäts-Score statt
|
||||
/// nach Datum: unbearbeitete/überfällige Korrekturen oben, erledigte unten. "Korrektur läuft"
|
||||
/// wird bewusst nicht als eigener, manuell zu pflegender Status abgelegt (siehe
|
||||
/// `ExamCorrectionCounter`) — bleibt eine Klausur trotzdem lange in Bearbeitung hängen (typischer
|
||||
/// Grund: ein einzelner nie nachschreibender Schüler), fällt sie nach `StuckAfterDays` aus der
|
||||
/// dringenden Zone in eine ruhige "hängt fest"-Einstufung, statt dauerhaft oben zu kleben.
|
||||
public static class ExamPriorityService
|
||||
{
|
||||
public const int StuckAfterDays = 21;
|
||||
private const int CorrectionGraceDays = 3;
|
||||
|
||||
public static ExamPriorityInfo Evaluate(Exam exam, int expected, int evaluated, DateOnly today)
|
||||
{
|
||||
if (exam.Status == ExamStatus.Returned)
|
||||
return new ExamPriorityInfo(ExamListStatus.Returned, -100);
|
||||
|
||||
if (exam.Status == ExamStatus.Graded)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (exam.Status == ExamStatus.Planned)
|
||||
{
|
||||
var daysUntil = exam.Date.DayNumber - today.DayNumber;
|
||||
return new ExamPriorityInfo(ExamListStatus.Planned, Math.Clamp(150 - daysUntil, 20, 150));
|
||||
}
|
||||
|
||||
// Status == Conducted: Korrekturfortschritt entscheidet über die genaue Einstufung.
|
||||
var daysSince = today.DayNumber - exam.Date.DayNumber;
|
||||
|
||||
if (evaluated <= 0)
|
||||
{
|
||||
var score = daysSince <= CorrectionGraceDays ? 400 + daysSince * 10 : 600 + daysSince;
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingCorrection, score);
|
||||
}
|
||||
|
||||
if (expected > 0 && evaluated >= expected)
|
||||
return new ExamPriorityInfo(ExamListStatus.AwaitingReturn, 300);
|
||||
|
||||
if (daysSince > StuckAfterDays)
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionStuck, 90);
|
||||
|
||||
return new ExamPriorityInfo(ExamListStatus.CorrectionInProgress, 400 + daysSince * 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Zählt eigene Klausuren pro ISO-Kalenderwoche über alle Lerngruppen hinweg. Nutzer-Feedback:
|
||||
/// die klassenbezogene Kollisionsprüfung (mehrere Klausuren einer einzelnen Klasse in einer
|
||||
/// Woche) übernimmt bereits der schulische Klausurplaner (externes Klassenbuch) — was dort
|
||||
/// fehlt, ist die persönliche Belastung der Lehrkraft: mehr eigene Klausuren in derselben Woche
|
||||
/// bedeuten unabhängig von der Klasse mehr Erstellungsaufwand vorher und mehr Korrekturaufwand
|
||||
/// danach. Da eine LiteDB-Datei genau einem Nutzer gehört (siehe CLAUDE.md), sind "alle
|
||||
/// Klausuren in der Datenbank" bereits gleichbedeutend mit "meine Klausuren".
|
||||
/// </summary>
|
||||
public static class ExamWeekLoadService
|
||||
{
|
||||
/// Ab dieser Anzahl eigener Klausuren in derselben Woche gilt die Woche als spürbar belastet.
|
||||
/// Nutzer-Feedback: zwei Klausuren aus zwei unterrichteten Fächern in derselben Woche sind
|
||||
/// normal, eine dritte ist die eigentliche Häufung.
|
||||
public const int WarningThreshold = 3;
|
||||
|
||||
/// Anzahl Klausuren (ohne <paramref name="excludeExamId"/>, z.B. die gerade im Dialog
|
||||
/// bearbeitete), die in dieselbe ISO-Kalenderwoche wie <paramref name="date"/> fallen.
|
||||
public static int CountInSameWeek(IEnumerable<Exam> allExams, DateOnly date, Guid? excludeExamId = null)
|
||||
{
|
||||
var (year, week) = IsoWeek(date);
|
||||
return allExams.Count(e => e.Id != excludeExamId && IsoWeek(e.Date) == (year, week));
|
||||
}
|
||||
|
||||
/// Gruppiert die übergebenen Klausuren nach ISO-Kalenderwoche und liefert nur die Wochen mit
|
||||
/// mindestens <see cref="WarningThreshold"/> Klausuren, chronologisch sortiert — für einen
|
||||
/// vorausschauenden Überblick (z.B. Dashboard), nicht für die Einzelprüfung beim Anlegen.
|
||||
public static List<ExamWeekLoad> FindOverloadedWeeks(IEnumerable<Exam> exams) =>
|
||||
exams
|
||||
.GroupBy(e => IsoWeek(e.Date))
|
||||
.Where(g => g.Count() >= WarningThreshold)
|
||||
.Select(g => new ExamWeekLoad(g.Key.Year, g.Key.Week, WeekStart(g.Key.Year, g.Key.Week), g.Count()))
|
||||
.OrderBy(w => w.WeekStart)
|
||||
.ToList();
|
||||
|
||||
private static (int Year, int Week) IsoWeek(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return (ISOWeek.GetYear(dt), ISOWeek.GetWeekOfYear(dt));
|
||||
}
|
||||
|
||||
private static DateOnly WeekStart(int isoYear, int isoWeek) =>
|
||||
DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
|
||||
}
|
||||
|
||||
public readonly record struct ExamWeekLoad(int IsoYear, int IsoWeek, DateOnly WeekStart, int ExamCount)
|
||||
{
|
||||
public DateOnly WeekEnd => WeekStart.AddDays(6);
|
||||
}
|
||||
@@ -77,13 +77,29 @@ public class GradingService
|
||||
public double WeightedAverage(List<(string Grade, double Weight)> grades)
|
||||
{
|
||||
var numeric = grades
|
||||
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
|
||||
.Select(g => (Value: ParseGradeValue(g.Grade), g.Weight))
|
||||
.Where(g => g.Value.HasValue).ToList();
|
||||
if (numeric.Count == 0) return 0;
|
||||
var total = numeric.Sum(g => g.Weight);
|
||||
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
|
||||
}
|
||||
|
||||
/// Wandelt eine Notenangabe mit optionaler Tendenz ("3+", "2-") in einen rechnerischen
|
||||
/// Notenwert um. Tendenzen verschieben um ein Drittel in Richtung der besseren Note
|
||||
/// (z.B. "3+" → 2,67, "2-" → 2,33) — der in deutschen Notensystemen übliche Drittelabstand.
|
||||
private static double? ParseGradeValue(string grade)
|
||||
{
|
||||
var trimmed = grade.Trim();
|
||||
if (trimmed.Length == 0) return null;
|
||||
|
||||
var tendency = 0.0;
|
||||
var core = trimmed;
|
||||
if (core.EndsWith('+')) { tendency = -1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
else if (core.EndsWith('-')) { tendency = 1.0 / 3.0; core = core[..^1].TrimEnd(); }
|
||||
|
||||
return int.TryParse(core, out var n) ? n + tendency : null;
|
||||
}
|
||||
|
||||
/// Rundet einen rechnerischen Notenwert auf eine ganze Note/Punktzahl.
|
||||
/// "Kaufmännisch" rundet bei genau 0,5 immer vom Nullpunkt weg (Standard).
|
||||
/// "Pädagogisch" rundet bei genau 0,5 in Richtung der besseren Note
|
||||
|
||||
@@ -60,6 +60,9 @@ public sealed class GroupRolloverService(
|
||||
IsActive = true,
|
||||
IsOwnClass = source.IsOwnClass,
|
||||
IsDifferentiated = source.IsDifferentiated,
|
||||
PreviousGroupId = source.Id,
|
||||
// WebUntisLessonId bewusst nicht übernommen: WebUntis vergibt sie pro Schuljahr neu,
|
||||
// eine übernommene alte lsid würde in der Folgegruppe stumm falsche Fehlzeiten liefern.
|
||||
};
|
||||
|
||||
var sourceWasActive = source.IsActive;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gemeinsame Terminlogik für Verschieben und Trennen von Stunden.</summary>
|
||||
public sealed class LessonSchedulingService(ILessonRepository lessons)
|
||||
{
|
||||
public void Move(Lesson lesson, DateOnly newDate, int? newPeriod, bool shiftFollowing)
|
||||
{
|
||||
EnsureTargetIsFree(lesson, newDate, newPeriod);
|
||||
var oldDate = lesson.Date;
|
||||
var delta = newDate.DayNumber - oldDate.DayNumber;
|
||||
|
||||
if (shiftFollowing && delta != 0)
|
||||
{
|
||||
foreach (var other in lessons.GetByUnit(lesson.UnitId))
|
||||
{
|
||||
if (other.Id == lesson.Id || other.Status is LessonStatus.Conducted or LessonStatus.Cancelled || other.Date <= oldDate)
|
||||
continue;
|
||||
other.Date = other.Date.AddDays(delta);
|
||||
lessons.Save(other);
|
||||
}
|
||||
}
|
||||
|
||||
lesson.Date = newDate;
|
||||
if (newPeriod.HasValue) lesson.LessonNumber = newPeriod;
|
||||
lessons.Save(lesson);
|
||||
}
|
||||
|
||||
public Lesson SplitAndMoveSecondPart(Lesson source, int splitAfterMinutes, DateOnly newDate,
|
||||
int newPeriod, TimeOnly? newStartTime)
|
||||
{
|
||||
if (splitAfterMinutes <= 0 || source.Phases.Sum(p => p.DurationMinutes) <= splitAfterMinutes)
|
||||
throw new InvalidOperationException("Der Verlauf reicht nicht über die erste Stunde hinaus.");
|
||||
EnsureTargetIsFree(source, newDate, newPeriod);
|
||||
|
||||
var first = new List<LessonPhaseStep>();
|
||||
var second = new List<LessonPhaseStep>();
|
||||
var elapsed = 0;
|
||||
foreach (var phase in source.Phases)
|
||||
{
|
||||
var remainingInFirst = splitAfterMinutes - elapsed;
|
||||
if (remainingInFirst <= 0)
|
||||
second.Add(Clone(phase, phase.DurationMinutes));
|
||||
else if (phase.DurationMinutes <= remainingInFirst)
|
||||
first.Add(Clone(phase, phase.DurationMinutes));
|
||||
else
|
||||
{
|
||||
first.Add(Clone(phase, remainingInFirst));
|
||||
second.Add(Clone(phase, phase.DurationMinutes - remainingInFirst));
|
||||
}
|
||||
elapsed += phase.DurationMinutes;
|
||||
}
|
||||
|
||||
source.Phases = first;
|
||||
var continuation = new Lesson
|
||||
{
|
||||
UnitId = source.UnitId,
|
||||
GroupId = source.GroupId,
|
||||
Date = newDate,
|
||||
LessonNumber = newPeriod,
|
||||
StartTime = newStartTime,
|
||||
Topic = string.IsNullOrWhiteSpace(source.Topic) ? "Fortsetzung" : $"{source.Topic} – Fortsetzung",
|
||||
Phases = second,
|
||||
Homework = source.Homework,
|
||||
HomeworkChecked = source.HomeworkChecked,
|
||||
HomeworkCheckDismissed = source.HomeworkCheckDismissed,
|
||||
Reflection = source.Reflection,
|
||||
Status = source.Status is LessonStatus.Conducted or LessonStatus.Cancelled ? LessonStatus.Draft : source.Status,
|
||||
};
|
||||
source.Homework = null;
|
||||
source.HomeworkChecked = false;
|
||||
source.HomeworkCheckDismissed = false;
|
||||
source.Reflection = null;
|
||||
lessons.Save(source);
|
||||
lessons.Save(continuation);
|
||||
return continuation;
|
||||
}
|
||||
|
||||
private void EnsureTargetIsFree(Lesson source, DateOnly date, int? period)
|
||||
{
|
||||
if (period is null) return;
|
||||
var occupied = lessons.GetByGroupAndDate(source.GroupId, date)
|
||||
.Any(l => l.Id != source.Id && l.LessonNumber == period);
|
||||
if (occupied)
|
||||
throw new InvalidOperationException($"Für die Lerngruppe existiert am {date:dd.MM.yyyy} in der {period}. Stunde bereits eine Planung.");
|
||||
}
|
||||
|
||||
private static LessonPhaseStep Clone(LessonPhaseStep source, int duration) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
DurationMinutes = duration,
|
||||
Activity = source.Activity,
|
||||
Material = source.Material,
|
||||
Shorthand = source.Shorthand,
|
||||
AlternativePathId = source.AlternativePathId,
|
||||
};
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
public enum TemplateIssueSeverity
|
||||
{
|
||||
Warning,
|
||||
StrongWarning,
|
||||
Error,
|
||||
}
|
||||
|
||||
public sealed record LetterPlaceholder(string Tag, string Description);
|
||||
|
||||
public sealed record TemplateValidationIssue(
|
||||
TemplateIssueSeverity Severity,
|
||||
string Message,
|
||||
string? Tag = null,
|
||||
string? SuggestedTag = null);
|
||||
|
||||
public sealed record TemplateValidationResult(
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<TemplateValidationIssue> Issues)
|
||||
{
|
||||
public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error);
|
||||
public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning);
|
||||
}
|
||||
|
||||
public sealed class LetterTemplateInfo
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = "";
|
||||
public string StoredFileName { get; set; } = "";
|
||||
public string OriginalFileName { get; set; } = "";
|
||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
|
||||
/// Die Originalvorlage wird nie verändert.
|
||||
/// </summary>
|
||||
public sealed class LetterTemplateService
|
||||
{
|
||||
private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static IReadOnlyList<LetterPlaceholder> SupportedPlaceholders { get; } =
|
||||
[
|
||||
new("Student.FirstName", "Vorname des Schülers"),
|
||||
new("Student.LastName", "Nachname des Schülers"),
|
||||
new("Contact.Name", "Name des ausgewählten Kontakts"),
|
||||
new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"),
|
||||
new("Contact.Street", "Straße und Hausnummer"),
|
||||
new("Contact.PostalCode", "Postleitzahl"),
|
||||
new("Contact.City", "Ort"),
|
||||
new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"),
|
||||
new("Group.Name", "Ausgewählte Lerngruppe"),
|
||||
new("SchoolYear", "Schuljahr der ausgewählten Gruppe"),
|
||||
new("CurrentDate", "Gewähltes Briefdatum"),
|
||||
];
|
||||
|
||||
private readonly string _templateDirectory;
|
||||
private readonly string _indexPath;
|
||||
|
||||
public LetterTemplateService(string appDataPath)
|
||||
{
|
||||
_templateDirectory = Path.Combine(appDataPath, "letter-templates");
|
||||
_indexPath = Path.Combine(_templateDirectory, "templates.json");
|
||||
Directory.CreateDirectory(_templateDirectory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LetterTemplateInfo> GetTemplates() => LoadIndex()
|
||||
.OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public string GetTemplatePath(LetterTemplateInfo template) =>
|
||||
Path.Combine(_templateDirectory, template.StoredFileName);
|
||||
|
||||
public LetterTemplateInfo Import(string sourcePath, string? displayName = null)
|
||||
{
|
||||
var validation = Validate(sourcePath);
|
||||
if (!validation.CanGenerate)
|
||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
||||
|
||||
var templates = LoadIndex();
|
||||
var info = new LetterTemplateInfo
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(displayName)
|
||||
? Path.GetFileNameWithoutExtension(sourcePath)
|
||||
: displayName.Trim(),
|
||||
OriginalFileName = Path.GetFileName(sourcePath),
|
||||
};
|
||||
info.StoredFileName = $"{info.Id:N}.docx";
|
||||
File.Copy(sourcePath, GetTemplatePath(info), overwrite: false);
|
||||
templates.Add(info);
|
||||
SaveIndex(templates);
|
||||
return info;
|
||||
}
|
||||
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var templates = LoadIndex();
|
||||
var template = templates.FirstOrDefault(t => t.Id == id);
|
||||
if (template is null) return;
|
||||
var path = GetTemplatePath(template);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
templates.Remove(template);
|
||||
SaveIndex(templates);
|
||||
}
|
||||
|
||||
public TemplateValidationResult Validate(LetterTemplateInfo template) =>
|
||||
Validate(GetTemplatePath(template));
|
||||
|
||||
public TemplateValidationResult Validate(string path)
|
||||
{
|
||||
var tags = new List<string>();
|
||||
var issues = new List<TemplateValidationIssue>();
|
||||
|
||||
if (!File.Exists(path))
|
||||
return Error("Die Vorlagendatei wurde nicht gefunden.");
|
||||
if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
return Error("Die Vorlage muss eine DOCX-Datei sein.");
|
||||
|
||||
try
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(path);
|
||||
var xmlEntries = WordXmlEntries(archive).ToList();
|
||||
if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase)))
|
||||
return Error("Die Datei ist kein gültiges Word-DOCX-Dokument.");
|
||||
|
||||
foreach (var entry in xmlEntries)
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace);
|
||||
foreach (var control in document.Descendants(W + "sdt"))
|
||||
{
|
||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
issues.Add(new(TemplateIssueSeverity.StrongWarning,
|
||||
$"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden."));
|
||||
continue;
|
||||
}
|
||||
tags.Add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument.");
|
||||
}
|
||||
catch (System.Xml.XmlException)
|
||||
{
|
||||
return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}");
|
||||
}
|
||||
|
||||
if (tags.Count == 0 && issues.Count == 0)
|
||||
issues.Add(new(TemplateIssueSeverity.Warning,
|
||||
"Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt."));
|
||||
|
||||
foreach (var tag in tags.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue;
|
||||
var suggestion = FindSuggestion(tag);
|
||||
issues.Add(suggestion is null
|
||||
? new(TemplateIssueSeverity.Warning,
|
||||
$"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag)
|
||||
: new(TemplateIssueSeverity.StrongWarning,
|
||||
$"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion));
|
||||
}
|
||||
|
||||
return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues);
|
||||
|
||||
TemplateValidationResult Error(string message) =>
|
||||
new([], [new(TemplateIssueSeverity.Error, message)]);
|
||||
}
|
||||
|
||||
public void Generate(string templatePath, string outputPath,
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
var validation = Validate(templatePath);
|
||||
if (!validation.CanGenerate)
|
||||
throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message);
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
using var source = ZipFile.OpenRead(templatePath);
|
||||
using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create);
|
||||
foreach (var entry in source.Entries)
|
||||
{
|
||||
var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal);
|
||||
targetEntry.LastWriteTime = entry.LastWriteTime;
|
||||
using var input = entry.Open();
|
||||
using var output = targetEntry.Open();
|
||||
|
||||
if (!IsWordXmlEntry(entry))
|
||||
{
|
||||
input.CopyTo(output);
|
||||
continue;
|
||||
}
|
||||
|
||||
var document = XDocument.Load(input, LoadOptions.PreserveWhitespace);
|
||||
foreach (var control in document.Descendants(W + "sdt").ToList())
|
||||
{
|
||||
var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim();
|
||||
if (tag is null || !values.TryGetValue(tag, out var value)) continue;
|
||||
ReplaceContent(control.Element(W + "sdtContent"), value ?? "");
|
||||
}
|
||||
document.Save(output, SaveOptions.DisableFormatting);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReplaceContent(XElement? content, string value)
|
||||
{
|
||||
if (content is null) return;
|
||||
var paragraph = content.Elements(W + "p").FirstOrDefault();
|
||||
XElement run;
|
||||
|
||||
if (paragraph is not null)
|
||||
{
|
||||
run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r");
|
||||
var paragraphProperties = paragraph.Element(W + "pPr");
|
||||
var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
||||
paragraph.RemoveNodes();
|
||||
if (paragraphProperties is not null) paragraph.Add(paragraphProperties);
|
||||
run = new XElement(W + "r");
|
||||
if (runProperties is not null) run.Add(runProperties);
|
||||
paragraph.Add(run);
|
||||
foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove();
|
||||
foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove();
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstRun = content.Descendants(W + "r").FirstOrDefault();
|
||||
var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null;
|
||||
content.RemoveNodes();
|
||||
run = new XElement(W + "r");
|
||||
if (runProperties is not null) run.Add(runProperties);
|
||||
content.Add(run);
|
||||
}
|
||||
|
||||
var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0) run.Add(new XElement(W + "br"));
|
||||
run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ZipArchiveEntry> WordXmlEntries(ZipArchive archive) =>
|
||||
archive.Entries.Where(IsWordXmlEntry);
|
||||
|
||||
private static bool IsWordXmlEntry(ZipArchiveEntry entry) =>
|
||||
entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase)
|
||||
&& entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string PartLabel(string path) => path switch
|
||||
{
|
||||
"word/document.xml" => "Dokumenttext",
|
||||
var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile",
|
||||
var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile",
|
||||
_ => $"dem Dokumentteil „{Path.GetFileName(path)}“",
|
||||
};
|
||||
|
||||
private static string? FindSuggestion(string unknown)
|
||||
{
|
||||
var candidate = SupportedPlaceholders
|
||||
.Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant())))
|
||||
.OrderBy(x => x.Distance)
|
||||
.ThenBy(x => x.Tag)
|
||||
.First();
|
||||
var threshold = candidate.Tag.Length >= 12 ? 3 : 2;
|
||||
return candidate.Distance <= threshold ? candidate.Tag : null;
|
||||
}
|
||||
|
||||
private static int Levenshtein(string left, string right)
|
||||
{
|
||||
var previous = Enumerable.Range(0, right.Length + 1).ToArray();
|
||||
for (var i = 1; i <= left.Length; i++)
|
||||
{
|
||||
var current = new int[right.Length + 1];
|
||||
current[0] = i;
|
||||
for (var j = 1; j <= right.Length; j++)
|
||||
current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1),
|
||||
previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1));
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.Length];
|
||||
}
|
||||
|
||||
private List<LetterTemplateInfo> LoadIndex()
|
||||
{
|
||||
if (!File.Exists(_indexPath)) return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex(List<LetterTemplateInfo> templates)
|
||||
{
|
||||
var temporaryPath = _indexPath + ".tmp";
|
||||
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
|
||||
File.Move(temporaryPath, _indexPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Gewichte für ClassTeacherRosterRow.PatternScore (Klassenlehrer-Bereich, "Gesamtbild")
|
||||
/// — Default-Werte entsprechen den ursprünglich fest verdrahteten Konstanten.</summary>
|
||||
public sealed class PatternScoreWeights
|
||||
{
|
||||
public double UnexcusedDayWeight { get; set; } = 3.0;
|
||||
public double ExcusedDayWeight { get; set; } = 1.0;
|
||||
public double LateDayWeight { get; set; } = 0.5;
|
||||
public double NegativeClassRegisterEntryWeight { get; set; } = 1.0;
|
||||
public double ConcerningKeywordBonus { get; set; } = 1.5;
|
||||
public double SuspensionKeywordWeight { get; set; } = 15.0;
|
||||
}
|
||||
|
||||
/// <summary>Speichert die Gewichte lokal, gleiches Muster wie WorkloadSettingsService/
|
||||
/// DashboardSettingsService.</summary>
|
||||
public sealed class PatternScoreSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public PatternScoreSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "patternscoreweights.json");
|
||||
|
||||
public PatternScoreWeights Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<PatternScoreWeights>(File.ReadAllText(_configPath))
|
||||
?? new PatternScoreWeights();
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> Standardgewichte */ }
|
||||
return new PatternScoreWeights();
|
||||
}
|
||||
|
||||
public void Save(PatternScoreWeights weights) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(weights));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// Welcher der drei Leistungsbereiche (siehe GradingScheme) bei der Zielnoten-Frage der
|
||||
/// gesuchte/unbekannte ist.
|
||||
public enum GradeBucketKind { Exams, Participation, Other }
|
||||
|
||||
public readonly record struct TargetGradeResult(double RequiredAverage, bool IsAchievable);
|
||||
|
||||
/// <summary>
|
||||
/// Beantwortet die Zielnoten-Frage "Was brauche ich noch, um Zeugnisnote X zu erreichen?"
|
||||
/// (Nutzer-Feedback) — kehrt <see cref="GradingService.CalculateReportGrade"/> um: löst nach
|
||||
/// dem Durchschnitt eines gewählten Bereichs auf, der zusammen mit den bekannten Durchschnitten
|
||||
/// der übrigen Bereiche (gewichtet nach <see cref="GradingScheme"/>) genau die Zielnote ergibt.
|
||||
/// Bereiche ohne bekannten Durchschnitt (z.B. noch keine Mitarbeitsnote im Halbjahr) fließen
|
||||
/// nicht in die bekannte Seite der Gleichung ein — exakt dieselbe Normierung wie
|
||||
/// <see cref="GradingService.CalculateReportGrade"/> bei fehlenden Bereichen.
|
||||
/// </summary>
|
||||
public static class ReportGradeTargetCalculator
|
||||
{
|
||||
public static TargetGradeResult? SolveRequiredAverage(double target, GradeBucketKind solveFor,
|
||||
double? examsAverage, double? participationAverage, double? otherAverage,
|
||||
GradingScheme scheme, GradingSystem system)
|
||||
{
|
||||
var solvePercent = solveFor switch
|
||||
{
|
||||
GradeBucketKind.Exams => scheme.ExamsPercent,
|
||||
GradeBucketKind.Participation => scheme.ParticipationPercent,
|
||||
_ => scheme.OtherPercent,
|
||||
};
|
||||
// Dieser Bereich fließt laut Gewichtungsschema gar nicht in die Zeugnisnote ein —
|
||||
// keine Zielnoten-Frage für ihn beantwortbar.
|
||||
if (solvePercent <= 0) return null;
|
||||
|
||||
var known = new List<(double Avg, double Percent)>();
|
||||
if (solveFor != GradeBucketKind.Exams && examsAverage is { } e) known.Add((e, scheme.ExamsPercent));
|
||||
if (solveFor != GradeBucketKind.Participation && participationAverage is { } p) known.Add((p, scheme.ParticipationPercent));
|
||||
if (solveFor != GradeBucketKind.Other && otherAverage is { } o) known.Add((o, scheme.OtherPercent));
|
||||
|
||||
var totalPercent = known.Sum(k => k.Percent) + solvePercent;
|
||||
var knownContribution = known.Sum(k => k.Avg * k.Percent);
|
||||
var required = (target * totalPercent - knownContribution) / solvePercent;
|
||||
|
||||
var (min, max) = system == GradingSystem.Grades1To6 ? (1.0, 6.0) : (0.0, 15.0);
|
||||
return new TargetGradeResult(required, required >= min && required <= max);
|
||||
}
|
||||
}
|
||||
@@ -193,4 +193,21 @@ public sealed class ChangeHookCascadeTests
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Documentation), "Delete"), call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_LoestSaveMitIsDeletedAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Konflikt" };
|
||||
repo.Save(vorgang);
|
||||
var calls = new List<(string EntityType, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, op, payload));
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal((nameof(Vorgang), "Save"), (call.EntityType, call.Operation));
|
||||
Assert.True(((Vorgang)call.Payload!).IsDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,4 +57,24 @@ public sealed class ChangeHookTests
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
// Kein eigener Fall in ChangeHookMatrixTests: UntisHubJobStateRepository kennt kein Delete
|
||||
// (siehe IUntisHubJobStateRepository), passt also nicht in deren Save+Delete-Tabellenform.
|
||||
[Fact]
|
||||
public void UntisHubJobStateRepository_Save_LoestOnChangeAus()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var calls = new List<(string EntityType, string EntityId, string Operation, object? Payload)>();
|
||||
db.OnChange = (type, id, op, payload) => calls.Add((type, id, op, payload));
|
||||
var repo = new UntisHubJobStateRepository(db);
|
||||
var state = new UntisHubJobState { Kind = UntisHubJobKind.OffenePeriods, LastRunAt = DateTime.UtcNow };
|
||||
|
||||
repo.Save(state);
|
||||
|
||||
var call = Assert.Single(calls);
|
||||
Assert.Equal(nameof(UntisHubJobState), call.EntityType);
|
||||
Assert.Equal(state.Id.ToString(), call.EntityId);
|
||||
Assert.Equal("Save", call.Operation);
|
||||
Assert.Same(state, call.Payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,65 @@ public sealed class DatabaseEncryptionServiceTests
|
||||
Assert.Single(reopened.GetCollection<BsonDocument>("t").FindAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_OhneVorhandeneDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeUnverschluesselteDatenbank_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(temp.Path))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_GueltigeVerschluesselteDatenbankMitRichtigemPasswort_GibtTrueZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.True(service.CanOpenAndRead(temp.Path, "geheim123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_FalschesPasswort_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }))
|
||||
db.GetCollection<BsonDocument>("t").Insert(new BsonDocument { ["x"] = 1 });
|
||||
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, "falsch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanOpenAndRead_BeschaedigteDatei_GibtFalseZurueck()
|
||||
{
|
||||
using var temp = new TempDatabase();
|
||||
// Eine kurze Textdatei initialisiert LiteDB stillschweigend als neue, leere Datenbank
|
||||
// (kleiner als eine Seite) — erst genug Datenmüll jenseits der Seitengröße lässt das
|
||||
// Datei-Layout tatsächlich fehlschlagen und simuliert eine echte Beschädigung
|
||||
// (z.B. durch einen vollen Datenträger während der Backup-Kopie).
|
||||
File.WriteAllBytes(temp.Path, Enumerable.Repeat((byte)0x42, 32_768).ToArray());
|
||||
var service = new DatabaseEncryptionService();
|
||||
|
||||
Assert.False(service.CanOpenAndRead(temp.Path, null));
|
||||
}
|
||||
|
||||
private sealed class TempDatabase : IDisposable
|
||||
{
|
||||
private readonly string _directory = System.IO.Path.Combine(
|
||||
|
||||
@@ -715,6 +715,67 @@ public sealed class RepositoryTests
|
||||
Assert.Null(storage.OpenRead(attachmentId));
|
||||
}
|
||||
|
||||
// ── VorgangRepository ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Save_SetztUpdatedAt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Schuleschwänzen Max" };
|
||||
|
||||
repo.Save(vorgang);
|
||||
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True((DateTime.UtcNow - raw!.UpdatedAt).TotalSeconds < 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetByStudent_FindetVorgaengeMitMehrerenSchuelern()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var ada = Guid.NewGuid();
|
||||
var ben = Guid.NewGuid();
|
||||
var other = Guid.NewGuid();
|
||||
var shared = new Vorgang { Title = "Konflikt", StudentIds = [ada, ben] };
|
||||
var unrelated = new Vorgang { Title = "Anderes", StudentIds = [other] };
|
||||
repo.Save(shared);
|
||||
repo.Save(unrelated);
|
||||
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ada).Select(v => v.Id));
|
||||
Assert.Equal([shared.Id], repo.GetByStudent(ben).Select(v => v.Id));
|
||||
Assert.Empty(repo.GetByStudent(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_Delete_MarkiertNurAlsGeloescht()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
var vorgang = new Vorgang { Title = "Hausaufgaben fehlen" };
|
||||
repo.Save(vorgang);
|
||||
|
||||
repo.Delete(vorgang.Id);
|
||||
|
||||
Assert.Empty(repo.GetAll());
|
||||
Assert.Null(repo.GetById(vorgang.Id));
|
||||
var raw = db.Vorgaenge.FindById(vorgang.Id);
|
||||
Assert.NotNull(raw);
|
||||
Assert.True(raw!.IsDeleted);
|
||||
Assert.NotNull(raw.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorgangRepository_GetById_LiefertNullFuerUnbekannteId()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new VorgangRepository(db);
|
||||
|
||||
Assert.Null(repo.GetById(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
// ── LessonRepository ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -1053,6 +1114,101 @@ public sealed class RepositoryTests
|
||||
Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId);
|
||||
}
|
||||
|
||||
// ── UntisAbsenceCacheRepository / UntisClassRegisterCacheRepository / UntisCacheFetchStateRepository ──
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_GetByClassAndRange_FiltertKlasseUndDatumsbereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260824 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Max", Date = 20260901 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10d", StudentName = "Ben", Date = 20260824 },
|
||||
]);
|
||||
|
||||
var result = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(20260824, result[0].Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisAbsenceCacheRepository_ReplaceRange_ErsetztNurDenAngegebenenBereich()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisAbsenceCacheRepository(db);
|
||||
repo.InsertRange(
|
||||
[
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260810 },
|
||||
new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Alt", Date = 20260901 },
|
||||
]);
|
||||
|
||||
repo.ReplaceRange("10c", 20260801, 20260831,
|
||||
[new UntisAbsenceCacheEntry { ClassName = "10c", StudentName = "Neu", Date = 20260815 }]);
|
||||
|
||||
var inRange = repo.GetByClassAndRange("10c", 20260801, 20260831);
|
||||
var untouched = repo.GetByClassAndRange("10c", 20260901, 20260901);
|
||||
Assert.Equal("Neu", Assert.Single(inRange).StudentName);
|
||||
Assert.Single(untouched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisClassRegisterCacheRepository_InsertUndGetByClassAndRange_RoundTrip()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisClassRegisterCacheRepository(db);
|
||||
repo.InsertRange([new UntisClassRegisterCacheEntry
|
||||
{
|
||||
ClassName = "10c", Date = 20260824, StudentName = "Max", Subject = "Deu",
|
||||
TeacherUsername = "mueller", CategoryName = "Positiv",
|
||||
}]);
|
||||
|
||||
var result = Assert.Single(repo.GetByClassAndRange("10c", 20260801, 20260831));
|
||||
Assert.Equal("mueller", result.TeacherUsername);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisStudentRosterCacheRepository_ReplaceAll_ErsetztNurDieAngegebeneKlasse()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisStudentRosterCacheRepository(db);
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Alt" }]);
|
||||
repo.ReplaceAll("10d", [new UntisStudentRosterCacheEntry { ClassName = "10d", DisplayName = "Andere Klasse" }]);
|
||||
|
||||
repo.ReplaceAll("10c", [new UntisStudentRosterCacheEntry { ClassName = "10c", DisplayName = "Neu", ExternKey = 42 }]);
|
||||
|
||||
var tenC = repo.GetByClass("10c");
|
||||
var tenD = repo.GetByClass("10d");
|
||||
Assert.Equal("Neu", Assert.Single(tenC).DisplayName);
|
||||
Assert.Equal(42, tenC[0].ExternKey);
|
||||
Assert.Single(tenD); // andere Klasse bleibt unberührt
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntisCacheFetchStateRepository_SaveUndGet_RoundTripJeKlasseUndArt()
|
||||
{
|
||||
using var db = NewInMemoryContext();
|
||||
var repo = new UntisCacheFetchStateRepository(db);
|
||||
var fetchedAt = new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc);
|
||||
repo.Save(new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = fetchedAt, ColdCoverageStartDate = 20260101,
|
||||
});
|
||||
|
||||
var result = repo.Get("10c", UntisCacheKind.Absences);
|
||||
var missing = repo.Get("10c", UntisCacheKind.ClassRegister);
|
||||
|
||||
Assert.NotNull(result);
|
||||
// LiteDB rundet Kind Utc->Local, verschiebt dabei die Ticks um denselben Zeitpunkt zu
|
||||
// erhalten (siehe CLAUDE.md) - deshalb erst beide Seiten auf UTC normalisieren.
|
||||
Assert.Equal(fetchedAt.ToUniversalTime(), result!.HotWindowFetchedAt!.Value.ToUniversalTime());
|
||||
Assert.Equal(20260101, result.ColdCoverageStartDate);
|
||||
Assert.Null(missing);
|
||||
}
|
||||
|
||||
// ── WorkTaskRepository ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -37,6 +37,29 @@ public class DatabaseEncryptionService
|
||||
}
|
||||
}
|
||||
|
||||
/// Öffnet die Datei probeweise und liest die Sammlungsliste, um eine strukturell gültige
|
||||
/// (nicht nur vorhandene) LiteDB-Datei zu bestätigen — z.B. direkt nach dem Anlegen eines
|
||||
/// Backups, das durch einen vollen Datenträger oder einen vorzeitig entfernten USB-Stick
|
||||
/// beschädigt worden sein könnte. Bewusst breites Catch: jede Art von Fehlschlag beim Öffnen
|
||||
/// oder Lesen bedeutet hier "Backup nicht verifizierbar", nicht nur ein falsches Passwort.
|
||||
public bool CanOpenAndRead(string dbPath, string? password)
|
||||
{
|
||||
if (!File.Exists(dbPath)) return false;
|
||||
try
|
||||
{
|
||||
var connection = password is null
|
||||
? new ConnectionString(dbPath)
|
||||
: new ConnectionString(dbPath) { Password = password };
|
||||
using var db = new LiteDatabase(connection);
|
||||
_ = db.GetCollectionNames().ToList();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das
|
||||
/// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere
|
||||
/// Verbindung (z.B. der laufende <see cref="LiteDbContext"/>) die Datei offen hält.
|
||||
|
||||
@@ -51,6 +51,7 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
|
||||
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
|
||||
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
|
||||
public ILiteCollection<Vorgang> Vorgaenge => _db.GetCollection<Vorgang>("vorgaenge");
|
||||
public ILiteStorage<string> Attachments => _db.GetStorage<string>("attachments", "attachments_chunks");
|
||||
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
|
||||
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
|
||||
@@ -68,6 +69,11 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
||||
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
||||
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
|
||||
public ILiteCollection<UntisAbsenceCacheEntry> UntisAbsenceCache => _db.GetCollection<UntisAbsenceCacheEntry>("untis_absence_cache");
|
||||
public ILiteCollection<UntisClassRegisterCacheEntry> UntisClassRegisterCache => _db.GetCollection<UntisClassRegisterCacheEntry>("untis_classregister_cache");
|
||||
public ILiteCollection<UntisCacheFetchState> UntisCacheFetchStates => _db.GetCollection<UntisCacheFetchState>("untis_cache_fetch_state");
|
||||
public ILiteCollection<UntisHubJobState> UntisHubJobStates => _db.GetCollection<UntisHubJobState>("untis_hub_job_states");
|
||||
public ILiteCollection<UntisStudentRosterCacheEntry> UntisStudentRosterCache => _db.GetCollection<UntisStudentRosterCacheEntry>("untis_student_roster_cache");
|
||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("trash");
|
||||
|
||||
@@ -535,6 +541,13 @@ public class LiteDbContext : IDisposable
|
||||
AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.StartDate);
|
||||
AnnualPlanEvents.EnsureIndex(x => x.EndDate);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.ClassName);
|
||||
UntisAbsenceCache.EnsureIndex(x => x.Date);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisClassRegisterCache.EnsureIndex(x => x.Date);
|
||||
UntisStudentRosterCache.EnsureIndex(x => x.ClassName);
|
||||
UntisCacheFetchStates.EnsureIndex("ux_class_kind",
|
||||
BsonExpression.Create("STRING($.ClassName) + ':' + STRING($.Kind)"), unique: true);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
@@ -357,6 +357,20 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
public void Save(Unit u)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, u.GroupId);
|
||||
// Persistenz-Invariante: auch Importe und künftig hinzukommende Aufrufer dürfen keine
|
||||
// zweite laufende Einheit in derselben Lerngruppe hinterlassen. Der Einheiten-Dialog
|
||||
// kündigt diese automatische Ablösung vorher sichtbar an.
|
||||
if (u.Status == UnitStatus.Active)
|
||||
{
|
||||
foreach (var previous in db.Units.Find(x => x.GroupId == u.GroupId &&
|
||||
x.Status == UnitStatus.Active && x.Id != u.Id))
|
||||
{
|
||||
previous.Status = UnitStatus.Completed;
|
||||
previous.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(previous);
|
||||
db.OnChange?.Invoke(nameof(Unit), previous.Id.ToString(), "Save", previous);
|
||||
}
|
||||
}
|
||||
u.UpdatedAt = DateTime.UtcNow;
|
||||
db.Units.Upsert(u);
|
||||
db.OnChange?.Invoke(nameof(Unit), u.Id.ToString(), "Save", u);
|
||||
@@ -372,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository
|
||||
|
||||
public class LessonRepository(LiteDbContext db) : ILessonRepository
|
||||
{
|
||||
public Lesson? GetById(Guid id) => db.Lessons.FindById(id);
|
||||
public List<Lesson> GetByUnit(Guid id) =>
|
||||
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
|
||||
@@ -431,6 +446,37 @@ public class DocumentationRepository(LiteDbContext db) : IDocumentationRepositor
|
||||
}
|
||||
}
|
||||
|
||||
public class VorgangRepository(LiteDbContext db) : IVorgangRepository
|
||||
{
|
||||
public List<Vorgang> GetAll() =>
|
||||
db.Vorgaenge.Find(v => !v.IsDeleted).OrderByDescending(v => v.UpdatedAt).ToList();
|
||||
public List<Vorgang> GetByStudent(Guid studentId) =>
|
||||
GetAll().Where(v => v.StudentIds.Contains(studentId)).ToList();
|
||||
public Vorgang? GetById(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
return vorgang is null || vorgang.IsDeleted ? null : vorgang;
|
||||
}
|
||||
public void Save(Vorgang vorgang)
|
||||
{
|
||||
vorgang.UpdatedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Upsert(vorgang);
|
||||
db.OnChange?.Invoke(nameof(Vorgang), vorgang.Id.ToString(), "Save", vorgang);
|
||||
}
|
||||
public void Delete(Guid id)
|
||||
{
|
||||
var vorgang = db.Vorgaenge.FindById(id);
|
||||
if (vorgang is null) return;
|
||||
vorgang.IsDeleted = true;
|
||||
vorgang.DeletedAt = DateTime.UtcNow;
|
||||
db.Vorgaenge.Update(vorgang);
|
||||
// Weiches Löschen ist inhaltlich eine Änderung, kein Entfernen -> "Save", damit ein
|
||||
// anwendendes Gerät den IsDeleted-Stand einfach übernimmt statt den Datensatz zu entfernen
|
||||
// (gleiches Muster wie DocumentationRepository.Delete).
|
||||
db.OnChange?.Invoke(nameof(Vorgang), id.ToString(), "Save", vorgang);
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
|
||||
{
|
||||
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
|
||||
@@ -492,6 +538,7 @@ public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSe
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) =>
|
||||
db.ParticipationSessions.Find(s => s.GroupId == groupId).OrderByDescending(s => s.Date).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => db.ParticipationSessions.FindById(id);
|
||||
public List<ParticipationSession> GetAll() => db.ParticipationSessions.FindAll().ToList();
|
||||
public void Save(ParticipationSession s)
|
||||
{
|
||||
ArchivedGroupWriteGuard.EnsureActive(db, s.GroupId);
|
||||
@@ -794,6 +841,79 @@ public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepos
|
||||
public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id);
|
||||
}
|
||||
|
||||
// Wie UntisSnapshotEntry/AnnualPlanEvent rein lokal, bewusst kein db.OnChange: Fehlzeiten- und
|
||||
// Klassenbuch-Cache fürs Klassenlehrer-Feature (siehe TODO.md) wachsen über ein Schuljahr auf viele
|
||||
// hundert Zeilen an - über Sync würde das nur Rauschen erzeugen, und jedes Gerät ruft WebUntis
|
||||
// ohnehin selbst ab (siehe UntisReportCacheService).
|
||||
public class UntisAbsenceCacheRepository(LiteDbContext db) : IUntisAbsenceCacheRepository
|
||||
{
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisAbsenceCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
db.UntisAbsenceCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => db.UntisAbsenceCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public class UntisClassRegisterCacheRepository(LiteDbContext db) : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
db.UntisClassRegisterCache
|
||||
.Find(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate)
|
||||
.ToList();
|
||||
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
db.UntisClassRegisterCache.DeleteMany(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => db.UntisClassRegisterCache.InsertBulk(entries);
|
||||
}
|
||||
|
||||
// Gleiches "bewusst kein db.OnChange"-Prinzip wie UntisAbsenceCacheRepository oben, auch wenn der
|
||||
// Roster selbst klein bleibt (aktuelle Klassenliste, keine Historie) - jedes Gerät ruft ihn ohnehin
|
||||
// selbst ab.
|
||||
public class UntisStudentRosterCacheRepository(LiteDbContext db) : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
db.UntisStudentRosterCache.Find(e => e.ClassName == className).ToList();
|
||||
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
db.UntisStudentRosterCache.DeleteMany(e => e.ClassName == className);
|
||||
db.UntisStudentRosterCache.InsertBulk(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public class UntisCacheFetchStateRepository(LiteDbContext db) : IUntisCacheFetchStateRepository
|
||||
{
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
db.UntisCacheFetchStates.FindOne(s => s.ClassName == className && s.Kind == kind);
|
||||
|
||||
public void Save(UntisCacheFetchState state) => db.UntisCacheFetchStates.Upsert(state);
|
||||
}
|
||||
|
||||
public class UntisHubJobStateRepository(LiteDbContext db) : IUntisHubJobStateRepository
|
||||
{
|
||||
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||
db.UntisHubJobStates.FindOne(s => s.Kind == kind && s.GroupId == groupId);
|
||||
|
||||
public List<UntisHubJobState> GetAll() => db.UntisHubJobStates.FindAll().ToList();
|
||||
|
||||
public void Save(UntisHubJobState state)
|
||||
{
|
||||
db.UntisHubJobStates.Upsert(state);
|
||||
db.OnChange?.Invoke(nameof(UntisHubJobState), state.Id.ToString(), "Save", state);
|
||||
}
|
||||
}
|
||||
|
||||
public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
|
||||
{
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
@@ -801,6 +921,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
|
||||
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.ToList();
|
||||
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||
db.CompetencyDomains
|
||||
.Find(d => d.SubjectId == subjectId)
|
||||
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder)
|
||||
.ToList();
|
||||
public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
|
||||
public void Save(CompetencyDomain d)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -15,6 +18,196 @@ public sealed class AiPlanningServiceTests
|
||||
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
||||
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
|
||||
{
|
||||
const string json = """
|
||||
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
|
||||
""";
|
||||
|
||||
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||
|
||||
Assert.Equal("Redox", lesson.Topic);
|
||||
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
|
||||
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
|
||||
{
|
||||
const string json = """
|
||||
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
|
||||
""";
|
||||
|
||||
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||
|
||||
Assert.Equal("Nur diese Stunde", lesson.Topic);
|
||||
Assert.Single(lesson.Phases);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
|
||||
{
|
||||
var error = Assert.Throws<AiBackendException>(() =>
|
||||
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
|
||||
|
||||
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
|
||||
{
|
||||
const string body = """
|
||||
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
|
||||
{
|
||||
const string body = """
|
||||
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
|
||||
""";
|
||||
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung()
|
||||
{
|
||||
var http = new HttpClient(new CanceledResponseHandler())
|
||||
{
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||
|
||||
Assert.Contains("3½ Minuten", error.Message);
|
||||
Assert.Contains("Webserver-Proxys", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
|
||||
{
|
||||
var group = new LearningGroup();
|
||||
var unit = new Unit { GroupId = group.Id, Title = "T" };
|
||||
var lessons = new FakeLessons();
|
||||
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
|
||||
|
||||
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
|
||||
vm.ImportAsNewCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Result);
|
||||
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))
|
||||
{
|
||||
BaseAddress = new Uri("https://example.invalid/"),
|
||||
};
|
||||
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
}
|
||||
|
||||
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class CanceledResponseHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
|
||||
new CancellationToken(canceled: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildContext_FuelltGruppenUndFachKontext()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -9,16 +9,12 @@ namespace LehrerApp.Desktop.Tests;
|
||||
public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}");
|
||||
|
||||
public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void OhneVorlage_KannKeinenBriefErzeugen()
|
||||
{
|
||||
var student = StudentWithContact("Sehr geehrte Frau Muster,");
|
||||
|
||||
var vm = Build(student, new LetterTemplateService(_directory));
|
||||
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -26,83 +22,313 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
[Fact]
|
||||
public void VerwendeteBriefanredeFehlt_BlockiertErzeugung()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation");
|
||||
var vm = Build(StudentWithContact(null), service);
|
||||
|
||||
var vm = Build(StudentWithContact(null), StoreWithTemplate(
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede"));
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie()
|
||||
public void VollstaendigeDaten_ErzeugenFlachesPdf()
|
||||
{
|
||||
var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate");
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service);
|
||||
var output = Path.Combine(_directory, "Brief.docx");
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
var output = Path.Combine(_directory, "Brief.pdf");
|
||||
|
||||
var generated = vm.Generate(output);
|
||||
|
||||
Assert.True(generated);
|
||||
Assert.True(File.Exists(output));
|
||||
using var archive = ZipFile.OpenRead(output);
|
||||
using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open());
|
||||
var xml = reader.ReadToEnd();
|
||||
Assert.Contains("Sehr geehrte Frau Muster,", xml);
|
||||
Assert.Contains("Lena", xml);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KontaktBearbeiten_SpeichertExpliziteBriefanrede()
|
||||
public void KonstanterPflichttext_BenoetigtKeineExterneEingabe()
|
||||
{
|
||||
var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" });
|
||||
vm.LetterSalutation = "Sehr geehrte Frau Muster,";
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline,
|
||||
Required: true, IsConstant: true, ConstantValue: "Fest im Vorlagenpaket"));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
var output = Path.Combine(_directory, "Konstant.pdf");
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation);
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) =>
|
||||
new(student, service, new FakeMemberships([]), new FakeGroups([]));
|
||||
[Fact]
|
||||
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||
var drawing = StudentAttendanceCalendarDrawingBuilder.Build("Lena Beispiel", new DateOnly(2026, 9, 1), [], []);
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]), (_, _, _, _) => drawing);
|
||||
var output = Path.Combine(_directory, "Kalender.pdf");
|
||||
|
||||
private LetterTemplateService ServiceWithTemplate(params string[] tags)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
|
||||
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
|
||||
{
|
||||
var entry = archive.CreateEntry("word/document.xml");
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
var controls = string.Join("", tags.Select(tag =>
|
||||
$"<w:sdt><w:sdtPr><w:tag w:val=\"{tag}\"/></w:sdtPr><w:sdtContent>" +
|
||||
"<w:p><w:r><w:t>Platzhalter</w:t></w:r></w:p></w:sdtContent></w:sdt>"));
|
||||
writer.Write("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">" +
|
||||
$"<w:body>{controls}</w:body></w:document>");
|
||||
Assert.True(vm.UsesAttendanceCalendar);
|
||||
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||
new DateOnly(2026, 8, 19), 3, AttendanceCalendarSize.Large));
|
||||
Assert.True(vm.AttendanceCalendarConfigured);
|
||||
Assert.Contains("August 2026", vm.AttendanceCalendarSummary);
|
||||
Assert.Contains("3 Monate", vm.AttendanceCalendarSummary);
|
||||
Assert.Contains("Groß", vm.AttendanceCalendarSummary);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
|
||||
service.Import(source, "Elternbrief");
|
||||
return service;
|
||||
|
||||
// Regression: Der DrawingValue-Builder hat früher immer eine feste Breite (170) angenommen,
|
||||
// unabhängig davon, wie breit die DRAWBOX in der jeweiligen Vorlage tatsächlich deklariert war -
|
||||
// bei breiteren (oder in "pt" statt "mm" deklarierten) Boxen blieb dadurch ein Großteil der
|
||||
// eigentlich verfügbaren Fläche ungenutzt leer (sichtbar in echten Elternbriefen als "Luft"
|
||||
// rechts/unterhalb des Kalenders). Der Dialog muss die real deklarierte Breite ermitteln und an
|
||||
// den Builder weiterreichen.
|
||||
[Fact]
|
||||
public void AdvancedContent_Anwesenheitskalender_NutztDieImLayoutDeklarierteBoxbreite()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||
};
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
TemplatePackage.Create(source, manifest,
|
||||
$"PAGE 210 297 mm\nDRAWBOX 20 20 250 80 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||
new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||
store.Import(source);
|
||||
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
|
||||
|
||||
Assert.True(vm.UsesAttendanceCalendar);
|
||||
Assert.Equal(250, receivedWidth);
|
||||
Assert.Equal(1, receivedMillimeterScale);
|
||||
}
|
||||
|
||||
// Regression: Eine reale, aus einem Word-Dokument übernommene Vorlage deklariert die Seite in
|
||||
// "pt" statt "mm" (PAGE ... pt). Ohne Umrechnung würde das mm-entworfene Kalenderraster dort nur
|
||||
// rund ein Drittel der vorgesehenen physischen Größe erreichen (1 "mm-Einheit" würde als 1pt statt
|
||||
// als ~2.83pt gerendert).
|
||||
[Fact]
|
||||
public void AdvancedContent_Anwesenheitskalender_RechnetBoxbreiteBeiPunktBasierterVorlageUm()
|
||||
{
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief",
|
||||
Placeholders = [new(StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true)],
|
||||
};
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
TemplatePackage.Create(source, manifest,
|
||||
$"PAGE 595.32 841.92 pt\nDRAWBOX 71 350 487 177 ${StudentAttendanceCalendarDrawingBuilder.PlaceholderName}",
|
||||
new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||
store.Import(source);
|
||||
float? receivedWidth = null; float? receivedMillimeterScale = null;
|
||||
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||
(_, width, millimeterScale, _) => { receivedWidth = width; receivedMillimeterScale = millimeterScale; return new DrawingValue([], 0); });
|
||||
|
||||
Assert.True(vm.UsesAttendanceCalendar);
|
||||
Assert.Equal(487, receivedWidth);
|
||||
Assert.NotNull(receivedMillimeterScale);
|
||||
Assert.Equal(2.8346f, receivedMillimeterScale!.Value, 0.001f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendanceKalenderKonfigurieren_LoestGezieltenDatenAbrufFuerDenZeitraumAus()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||
AttendanceCalendarOptions? requested = null;
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||
(_, _, _, _) => new DrawingValue([], 0), attendanceDataRefresher: (options, _) =>
|
||||
{ requested = options; return Task.CompletedTask; });
|
||||
|
||||
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||
new DateOnly(2026, 9, 1), 2, AttendanceCalendarSize.Medium));
|
||||
|
||||
Assert.NotNull(requested);
|
||||
Assert.Equal(new DateOnly(2026, 9, 1), requested!.NormalizedStartMonth);
|
||||
Assert.Equal(2, requested.NormalizedMonthCount);
|
||||
Assert.False(vm.IsRefreshingAttendanceData);
|
||||
Assert.Equal("", vm.AttendanceRefreshError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendanceKalenderKonfigurieren_ZeigtFehlerBeiFehlgeschlagenemAbrufAnStattZuBlockieren()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||
(_, _, _, _) => new DrawingValue([], 0),
|
||||
attendanceDataRefresher: (_, _) => throw new WebUntisIntegrationException("Keine Verbindung."));
|
||||
|
||||
await vm.SetAttendanceCalendarOptionsAsync(new AttendanceCalendarOptions(
|
||||
new DateOnly(2026, 9, 1), 1, AttendanceCalendarSize.Medium));
|
||||
|
||||
Assert.True(vm.AttendanceCalendarConfigured);
|
||||
Assert.Contains("Keine Verbindung.", vm.AttendanceRefreshError);
|
||||
Assert.False(vm.IsRefreshingAttendanceData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdvancedContent_Fehlzeitenliste_AktiviertKonfigurationsschritt()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition(
|
||||
StudentAbsenceDayListDrawingBuilder.PlaceholderName, PlaceholderType.Drawing, true));
|
||||
var drawing = StudentAbsenceDayListDrawingBuilder.Build("Lena Beispiel",
|
||||
new AttendanceCalendarOptions(new DateOnly(2026, 9, 1), 1), []);
|
||||
var vm = new CreateLetterDialogViewModel(StudentWithContact("Sehr geehrte Frau Muster,"), store,
|
||||
new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]),
|
||||
attendanceCalendarFactory: null, absenceDayListFactory: (_, _, _, _) => drawing);
|
||||
|
||||
Assert.False(vm.UsesAttendanceCalendar);
|
||||
Assert.True(vm.UsesAbsenceDayList);
|
||||
Assert.True(vm.UsesAttendanceAdvancedContent);
|
||||
Assert.True(vm.CanGenerate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
|
||||
var betreff = Assert.Single(vm.CustomPlaceholders);
|
||||
Assert.Equal("Betreff", betreff.Name);
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
betreff.TextValue = "Wichtiger Termin";
|
||||
var output = Path.Combine(_directory, "MitBetreff.pdf");
|
||||
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StudentName_WirdAutomatischMitVollemNamenBefuellt()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
|
||||
new PlaceholderDefinition("Student.Name", PlaceholderType.Text, true));
|
||||
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
|
||||
Assert.Empty(vm.CustomPlaceholders);
|
||||
Assert.True(vm.CanGenerate);
|
||||
var output = Path.Combine(_directory, "StudentName.pdf");
|
||||
Assert.True(vm.Generate(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anrede_WirdAusKontaktVorbelegtUndBleibtEditierbar()
|
||||
{
|
||||
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var vm = Build(StudentWithContact(null), store);
|
||||
vm.LetterText = "Dies ist der Inhalt.";
|
||||
|
||||
Assert.Equal("", vm.Anrede);
|
||||
Assert.False(vm.CanGenerate);
|
||||
Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
vm.Anrede = "Sehr geehrte Familie Beispiel,";
|
||||
var output = Path.Combine(_directory, "AnredeManuell.pdf");
|
||||
|
||||
Assert.True(vm.CanGenerate);
|
||||
Assert.True(vm.Generate(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddressPreview_FolgtDemGewaehltenKontaktUndAktualisiertSichBeimWechsel()
|
||||
{
|
||||
var student = new Student
|
||||
{
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts =
|
||||
[
|
||||
new Contact { Name = "Frau Beispiel", Relation = "Mutter", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" },
|
||||
new Contact { Name = "Herr Beispiel", Relation = "Vater", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" },
|
||||
],
|
||||
};
|
||||
var vm = Build(student, StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true)));
|
||||
|
||||
Assert.Equal(2, vm.Contacts.Count);
|
||||
Assert.Contains("Frau Beispiel", vm.AddressPreview);
|
||||
Assert.Contains("Erste Str. 1", vm.AddressPreview);
|
||||
|
||||
vm.SelectedContact = vm.Contacts.Single(c => c.Model.Name == "Herr Beispiel");
|
||||
|
||||
Assert.Contains("Herr Beispiel", vm.AddressPreview);
|
||||
Assert.Contains("Zweite Str. 2", vm.AddressPreview);
|
||||
Assert.DoesNotContain("Frau Beispiel", vm.AddressPreview);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectContactDialog_StartetBeimAktuellenKontaktUndAktualisiertVorschau()
|
||||
{
|
||||
var contacts = new List<LetterContactChoice>
|
||||
{
|
||||
new(new Contact { Name = "Frau Beispiel", Street = "Erste Str. 1", PostalCode = "11111", City = "Erststadt" }),
|
||||
new(new Contact { Name = "Herr Beispiel", Street = "Zweite Str. 2", PostalCode = "22222", City = "Zweitstadt" }),
|
||||
};
|
||||
var dialogVm = new SelectContactDialogViewModel(contacts, contacts[1]);
|
||||
|
||||
Assert.Same(contacts[1], dialogVm.SelectedContact);
|
||||
Assert.Contains("Zweite Str. 2", dialogVm.AddressPreview);
|
||||
|
||||
dialogVm.SelectedContact = contacts[0];
|
||||
|
||||
Assert.Contains("Erste Str. 1", dialogVm.AddressPreview);
|
||||
}
|
||||
|
||||
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
|
||||
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
|
||||
|
||||
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var element = definition.Type switch
|
||||
{
|
||||
PlaceholderType.Multiline => "TEXTBOX",
|
||||
PlaceholderType.Drawing => "DRAWBOX",
|
||||
_ => "TEXT",
|
||||
};
|
||||
lines.Add(element switch
|
||||
{
|
||||
"TEXTBOX" => $"TEXTBOX 20 {y} 170 80 ${definition.Name}",
|
||||
"DRAWBOX" => $"DRAWBOX 20 {y} 170 80 ${definition.Name}",
|
||||
_ => $"TEXT 20 {y} ${definition.Name}",
|
||||
});
|
||||
y += 20;
|
||||
}
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation) => new()
|
||||
{
|
||||
FirstName = "Lena",
|
||||
LastName = "Beispiel",
|
||||
Contacts =
|
||||
[
|
||||
new Contact
|
||||
{
|
||||
Name = "Frau Muster",
|
||||
Relation = "Mutter",
|
||||
LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1",
|
||||
PostalCode = "12345",
|
||||
City = "Musterstadt",
|
||||
},
|
||||
],
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }],
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using System.Globalization;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
@@ -11,6 +12,83 @@ namespace LehrerApp.Desktop.Tests;
|
||||
/// (offene Aufgaben, ...).
|
||||
public sealed class DashboardViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereHinweisbereiche_WerdenAusgeblendetUndZusammenfassungBleibtKompakt()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
|
||||
Assert.False(vm.AttentionCard.EffectiveIsVisible);
|
||||
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
|
||||
Assert.True(vm.TodayCard.EffectiveIsVisible);
|
||||
Assert.True(vm.CalendarCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
/// Regression: ApplyCardLayout zaehlte frueher IsVisible statt EffectiveIsVisible. Eine
|
||||
/// eingeschaltete, aber leere HideWhenEmpty-Kachel belegte damit einen Rasterplatz, den das
|
||||
/// Grid nie fuellt — im Alltag der Normalfall, weil meist mehrere Hinweiskacheln leer sind.
|
||||
[Fact]
|
||||
public void Kachelraster_LeereAusgeblendeteKacheln_HinterlassenKeineLuecke()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
|
||||
// Ohne mindestens eine leer ausgeblendete Kachel wuerde der Test nichts pruefen.
|
||||
Assert.False(vm.AttentionCard.EffectiveIsVisible);
|
||||
|
||||
var belegtePlaetze = vm.DashboardCards.Where(c => c.EffectiveIsVisible)
|
||||
.Select(c => c.Row * 2 + c.Column).OrderBy(slot => slot).ToList();
|
||||
Assert.Equal(Enumerable.Range(0, belegtePlaetze.Count), belegtePlaetze);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KachelMargin_FolgtDerBerechnetenSpalte()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
|
||||
foreach (var card in vm.DashboardCards.Where(c => c.EffectiveIsVisible))
|
||||
Assert.Equal(card.Column == 0
|
||||
? new Avalonia.Thickness(0, 0, 8, 8)
|
||||
: new Avalonia.Thickness(8, 0, 0, 8), card.Margin);
|
||||
}
|
||||
|
||||
/// Regression: der Margin hing fest im XAML an der Kachel. Wandert sie durch Aus-/Einblenden
|
||||
/// einer vorherigen Kachel in die andere Spalte, sass der Rinnstein auf der falschen Seite.
|
||||
[Fact]
|
||||
public void KachelAusblenden_DrehtDenMarginDerNachfolgendenKachel()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today });
|
||||
Assert.Equal(1, vm.TasksCard.Column);
|
||||
Assert.Equal(new Avalonia.Thickness(8, 0, 0, 8), vm.TasksCard.Margin);
|
||||
|
||||
vm.TodayCard.IsVisible = false;
|
||||
|
||||
Assert.Equal(0, vm.TasksCard.Column);
|
||||
Assert.Equal(new Avalonia.Thickness(0, 0, 8, 8), vm.TasksCard.Margin);
|
||||
}
|
||||
|
||||
/// Die frueheren sieben eigenen Listen (OpenExcuses, AttendanceWarnings, ...) sind zur
|
||||
/// zusammengefassten Attention-Karte verschmolzen (siehe AttentionItem.cs) — Tests greifen
|
||||
/// seither ueber Kind gefiltert zu statt ueber eine eigene Collection je Art.
|
||||
private static IEnumerable<AttentionItem> Items(DashboardViewModel vm, AttentionKind kind) =>
|
||||
vm.Attention.Where(g => g.Kind == kind).SelectMany(g => g.Items);
|
||||
|
||||
/// AttentionItem traegt fuer MissingTeachingTime kein eigenes Date-Feld (Title ist bereits der
|
||||
/// formatierte Anzeigetext) — das genaue Datum steckt im mitgegebenen Action-Parameter
|
||||
/// (derselbe MissingTeachingTimeItem, den auch der "Erfassen"-Dialog erhaelt).
|
||||
private static DateOnly MissingTimeDate(AttentionItem item) =>
|
||||
((MissingTeachingTimeItem)item.Actions.Single().Parameter!).Date;
|
||||
|
||||
private static PeriodScheduleService NewPeriodSchedule()
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
@@ -43,12 +121,13 @@ public sealed class DashboardViewModelTests
|
||||
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
|
||||
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null,
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null)
|
||||
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
|
||||
FakeTimeEntries? timeEntries = null, Func<DateTime>? now = null)
|
||||
{
|
||||
lessons ??= new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
return new DashboardViewModel(
|
||||
new FakeGroups([group]), new FakeSubjects([]), lessons,
|
||||
new FakeGroups(allGroups ?? [group]), new FakeSubjects([]), lessons,
|
||||
exams ?? new FakeExams([]), results ?? new FakeResults(), grades ?? new FakeGrades(),
|
||||
reportGrades ?? new FakeReportGrades(), memberships ?? new FakeMemberships([]),
|
||||
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
|
||||
@@ -56,7 +135,196 @@ public sealed class DashboardViewModelTests
|
||||
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
|
||||
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
|
||||
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
|
||||
substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents);
|
||||
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(),
|
||||
TestSupport.BuildUntisHubService(), TestSupport.BuildWebUntisIntegrationService(), annualPlanEvents,
|
||||
annualPlanSync: null, schoolWeather: null, now: now);
|
||||
}
|
||||
|
||||
/// Montag einer Woche, die garantiert in der Zukunft liegt und innerhalb der
|
||||
/// Klausurwochen-Vorausschau — unabhängig davon, welcher Wochentag "heute" gerade ist.
|
||||
private static DateOnly NextIsoWeekMonday()
|
||||
{
|
||||
var reference = DateTime.Today.AddDays(7);
|
||||
return DateOnly.FromDateTime(
|
||||
ISOWeek.ToDateTime(ISOWeek.GetYear(reference), ISOWeek.GetWeekOfYear(reference), DayOfWeek.Monday));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_DreiGeplanteKlausurenInDerselbenWoche_ErzeugtEintrag()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
var entry = Assert.Single(vm.ExamWeekLoads);
|
||||
Assert.Equal(3, entry.ExamCount);
|
||||
Assert.True(vm.ExamLoadCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_WenigeKlausurenProWoche_BleibtLeer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Planned },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
Assert.Empty(vm.ExamWeekLoads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamWeekLoads_BereitsDurchgefuehrteKlausurenZaehlenNicht()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var monday = NextIsoWeekMonday();
|
||||
var exams = new FakeExams([
|
||||
new Exam { GroupId = group.Id, Title = "a", Date = monday, Status = ExamStatus.Conducted },
|
||||
new Exam { GroupId = group.Id, Title = "b", Date = monday.AddDays(1), Status = ExamStatus.Graded },
|
||||
new Exam { GroupId = group.Id, Title = "c", Date = monday.AddDays(2), Status = ExamStatus.Planned },
|
||||
]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, exams: exams);
|
||||
|
||||
Assert.Empty(vm.ExamWeekLoads);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_VergangenerUnterrichtstagOhneErfassung_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots, periodSchedule: periodSchedule);
|
||||
|
||||
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||
Assert.True(vm.AttentionCard.EffectiveIsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_BereitsErfassterTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var timeEntries = new FakeTimeEntries();
|
||||
timeEntries.Add(new TimeEntry { Date = pastDay, Category = "Unterricht", DurationMinutes = 45 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, timeEntries: timeEntries);
|
||||
|
||||
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_KomplettAusgefallenerTag_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var substitutions = new FakeSubstitutionEntries();
|
||||
substitutions.Add(new SubstitutionEntry { Date = pastDay, Kind = SubstitutionKind.Cancelled, PeriodNumber = 1 });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, substitutions: substitutions);
|
||||
|
||||
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_SchulferienTagWirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var pastDay = today.AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var schoolHolidays = new FakeSchoolHolidays();
|
||||
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = pastDay, EndDate = pastDay });
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
slots: slots, periodSchedule: periodSchedule, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == pastDay);
|
||||
}
|
||||
|
||||
/// Fixer Referenzzeitpunkt fuer die beiden "heute, kurz vor/nach Ablauf der Wartezeit"-Tests
|
||||
/// unten. Regression: die Tests bauten "Unterrichtsende" bisher aus TimeOnly.FromDateTime(
|
||||
/// DateTime.Now).AddHours(±2) — TimeOnly wickelt bei Mitternacht um, ausgefuehrt zwischen ca.
|
||||
/// 22:00 und 02:00 Uhr wurde dadurch aus "+2h" ein Ende VOR "jetzt" (oder umgekehrt), je nach
|
||||
/// Tageszeit zufaellig rot. September ist bewusst gewaehlt: keiner der bundesweiten oder
|
||||
/// laenderspezifischen Feiertage (PublicHolidayService) faellt in diesen Monat, ein Dienstag
|
||||
/// ist garantiert kein Wochenende — die injizierte Uhr (DashboardViewModel now:-Parameter)
|
||||
/// macht "jetzt" fuer den Test unabhaengig von der tatsaechlichen Ausfuehrungsuhrzeit.
|
||||
private static readonly DateTime FixedNow = new(2026, 9, 8, 10, 0, 0);
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(FixedNow);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) noch keine 30 Minuten zurück.
|
||||
var futureEnd = TimeOnly.FromDateTime(FixedNow).AddHours(2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = futureEnd.AddHours(-1), End = futureEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||
|
||||
Assert.DoesNotContain(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(FixedNow);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = NewPeriodSchedule();
|
||||
// Unterrichtsende liegt (relativ zur fixen Uhr FixedNow) mehr als 30 Minuten zurück.
|
||||
var pastEnd = TimeOnly.FromDateTime(FixedNow).AddHours(-2);
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = pastEnd.AddHours(-1), End = pastEnd }]);
|
||||
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots,
|
||||
periodSchedule: periodSchedule, now: () => FixedNow);
|
||||
|
||||
Assert.Contains(Items(vm, AttentionKind.MissingTeachingTime), i => MissingTimeDate(i) == today);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -183,10 +451,9 @@ public sealed class DashboardViewModelTests
|
||||
exams: new FakeExams([exam]), results: results, memberships: memberships,
|
||||
students: new FakeStudents([anna, ben]));
|
||||
|
||||
var correction = Assert.Single(vm.OpenCorrections);
|
||||
Assert.Equal(1, correction.Completed);
|
||||
Assert.Equal(2, correction.Total);
|
||||
Assert.Equal(50, correction.Percent);
|
||||
var correction = Assert.Single(Items(vm, AttentionKind.Correction));
|
||||
Assert.Equal(50, correction.ProgressPercent);
|
||||
Assert.Equal("1 von 2 Arbeiten bewertet", correction.ProgressLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -207,7 +474,7 @@ public sealed class DashboardViewModelTests
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, grades: grades,
|
||||
memberships: memberships, students: new FakeStudents([student]));
|
||||
|
||||
Assert.Contains(vm.Alerts, a => a.StudentId == student.Id && a.KindLabel == "Notenabfall");
|
||||
Assert.Contains(Items(vm, AttentionKind.Alert), a => a.Title == student.FullName && a.TrailingText == "Notenabfall");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -226,6 +493,26 @@ public sealed class DashboardViewModelTests
|
||||
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Exam && e.Title == "Test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalenderauswahl_SortiertKurseDesTagesVorDenAlphabetischenRest()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var selectedDate = today.AddDays(1);
|
||||
var alphabeticallyFirst = new LearningGroup { Name = "10a" };
|
||||
var selectedDayGroup = new LearningGroup { Name = "WAT 10c" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = selectedDayGroup.Id, Date = selectedDate });
|
||||
var vm = BuildVm(alphabeticallyFirst,
|
||||
new Lesson { GroupId = alphabeticallyFirst.Id, Date = today }, lessons: lessons,
|
||||
allGroups: [alphabeticallyFirst, selectedDayGroup]);
|
||||
|
||||
vm.SelectCalendarDayCommand.Execute(vm.CalendarDays.Single(d => d.Date == selectedDate));
|
||||
|
||||
Assert.Equal(selectedDayGroup.Id, vm.CurrentGroups[0].GroupId);
|
||||
Assert.True(vm.CurrentGroups[0].IsOnSelectedDay);
|
||||
Assert.Equal(alphabeticallyFirst.Id, vm.CurrentGroups[1].GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kalender_ZeigtMitarbeitssitzungenAlsEigenenTermintyp()
|
||||
{
|
||||
@@ -346,9 +633,8 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
var item = Assert.Single(vm.UnplannedLessons);
|
||||
Assert.Equal(group.Id, item.GroupId);
|
||||
Assert.Equal(1, item.PeriodNumber);
|
||||
var item = Assert.Single(Items(vm, AttentionKind.Unplanned));
|
||||
Assert.Equal($"{group.Name} · 1. Stunde", item.Title);
|
||||
Assert.Equal("Heute", item.DateDisplay);
|
||||
}
|
||||
|
||||
@@ -363,7 +649,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -377,7 +663,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -393,7 +679,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -408,7 +694,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -423,9 +709,10 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, lesson, slots: slots);
|
||||
|
||||
Assert.Equal(2, vm.UnplannedLessons.Count);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
|
||||
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
|
||||
var unplanned = Items(vm, AttentionKind.Unplanned).ToList();
|
||||
Assert.Equal(2, unplanned.Count);
|
||||
Assert.Contains(unplanned, i => i.Title.Contains("3. Stunde"));
|
||||
Assert.Contains(unplanned, i => i.Title.Contains("4. Stunde"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -443,7 +730,7 @@ public sealed class DashboardViewModelTests
|
||||
|
||||
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
|
||||
|
||||
Assert.Empty(vm.UnplannedLessons);
|
||||
Assert.Empty(Items(vm, AttentionKind.Unplanned));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -466,7 +753,7 @@ public sealed class DashboardViewModelTests
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||
|
||||
Assert.Empty(vm.AttendanceWarnings);
|
||||
Assert.Empty(Items(vm, AttentionKind.Attendance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -490,9 +777,9 @@ public sealed class DashboardViewModelTests
|
||||
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
|
||||
students: new FakeStudents([student]), sessions: sessions, entries: entries);
|
||||
|
||||
var item = Assert.Single(vm.AttendanceWarnings);
|
||||
Assert.Equal(student.FullName, item.StudentName);
|
||||
Assert.Equal(30.0, item.AbsenceRatePercent);
|
||||
var item = Assert.Single(Items(vm, AttentionKind.Attendance));
|
||||
Assert.Equal(student.FullName, item.Title);
|
||||
Assert.Equal("30 %", item.TrailingText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -228,6 +228,20 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.NotEqual("", vm.StudentError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneGeladeneSchuelerlisteUndOhneKontext_SaveErzeugtKeinenVerwaistenEintrag()
|
||||
{
|
||||
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage())
|
||||
{
|
||||
Title = "Vorfall", TypeName = "Vorkommnis",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.Contains("noch nicht geladen", vm.StudentError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
|
||||
{
|
||||
@@ -247,6 +261,25 @@ public sealed class DocumentationDialogViewModelTests
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GesamteLerngruppe_SaveSpeichertOhneSchuelerbezug()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var wholeGroup = new StudentOption(Guid.Empty, "Gesamte Lerngruppe");
|
||||
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
|
||||
[wholeGroup], groupId)
|
||||
{
|
||||
SelectedStudent = wholeGroup, Title = "Klausur planen", TypeName = "Planung / Erinnerung",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(groupId, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MitStudentOptions_Bearbeiten_SchuelerIstVorausgewaehlt()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class ExamDialogViewModelTests
|
||||
{
|
||||
private static ExamDialogViewModel BuildViewModel(List<Exam> existingExams, string dateText,
|
||||
Exam? editingExam = null)
|
||||
{
|
||||
var vm = new ExamDialogViewModel(new FakeExams(existingExams), new FakeCompetencyDomains(),
|
||||
new FakeGradingKeyTemplates(), new GradingService(), Guid.NewGuid(), null, 9,
|
||||
GradingSystem.Grades1To6, "Chemie", isDifferentiated: false, editingExam, null);
|
||||
vm.DateText = dateText;
|
||||
return vm;
|
||||
}
|
||||
|
||||
private static Exam MakeExam(DateOnly date) => new() { GroupId = Guid.NewGuid(), Title = "x", Date = date };
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_WenigeKlausurenInDerWoche_BleibtLeer()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var existing = new List<Exam> { MakeExam(monday) };
|
||||
|
||||
var vm = BuildViewModel(existing, monday.AddDays(1).ToString("dd.MM.yyyy"));
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_DritteKlausurInDerWoche_ZeigtHinweis()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var existing = new List<Exam> { MakeExam(monday), MakeExam(monday.AddDays(1)) };
|
||||
|
||||
var vm = BuildViewModel(existing, monday.AddDays(2).ToString("dd.MM.yyyy"));
|
||||
|
||||
Assert.Contains("3 Klausuren", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_BearbeiteteKlausurZaehltNichtDoppelt()
|
||||
{
|
||||
var monday = new DateOnly(2026, 3, 16);
|
||||
var editing = MakeExam(monday);
|
||||
var existing = new List<Exam> { editing, MakeExam(monday.AddDays(1)) };
|
||||
|
||||
// Nur die eigene Klausur (editing) + eine weitere -> 2 insgesamt, kein Hinweis.
|
||||
var vm = BuildViewModel(existing, monday.ToString("dd.MM.yyyy"), editing);
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeekLoadHint_UngueltigesDatum_BleibtLeer()
|
||||
{
|
||||
var vm = BuildViewModel([], "keindatum");
|
||||
|
||||
Assert.Equal("", vm.WeekLoadHint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6.3 (Bulk-Aktion "Rest als abwesend markieren") ab — löst das Nutzer-Feedback, dass
|
||||
/// Schüler, die nie nachschreiben, sonst dauerhaft eine leere, unberührte Zeile hinterlassen.
|
||||
public sealed class ExamGradingDialogViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_MarktNurUnberührteZeilenAbwesend()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var studentWithPoints = new Student { FirstName = "Anna", LastName = "Bauer" };
|
||||
var untouchedStudent1 = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var untouchedStudent2 = new Student { FirstName = "Cara", LastName = "Diehl" };
|
||||
var students = new FakeStudents([studentWithPoints, untouchedStudent1, untouchedStudent2]);
|
||||
var memberships = new FakeMemberships(
|
||||
[
|
||||
new GroupMembership { GroupId = group.Id, StudentId = studentWithPoints.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent1.Id },
|
||||
new GroupMembership { GroupId = group.Id, StudentId = untouchedStudent2.Id },
|
||||
]);
|
||||
var exam = new Exam
|
||||
{
|
||||
GroupId = group.Id,
|
||||
Title = "Klausur 1",
|
||||
Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }],
|
||||
GradingKey = GradingService.DefaultKey1To6(),
|
||||
};
|
||||
var results = new FakeResults();
|
||||
|
||||
var vm = new ExamGradingDialogViewModel(results, students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
var rowWithPoints = vm.Rows.Single(r => r.StudentId == studentWithPoints.Id);
|
||||
rowWithPoints.Cells[0].TrySetValue(7);
|
||||
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.False(rowWithPoints.Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent1.Id).Absent);
|
||||
Assert.True(vm.Rows.Single(r => r.StudentId == untouchedStudent2.Id).Absent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkRemainingAbsent_LässtBereitsAbwesendMarkierteUnverändert()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var student = new Student { FirstName = "Ben", LastName = "Cordes" };
|
||||
var students = new FakeStudents([student]);
|
||||
var memberships = new FakeMemberships(
|
||||
[new GroupMembership { GroupId = group.Id, StudentId = student.Id }]);
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur 1", Tasks = [new ExamTask { Nr = 1, MaxPoints = 10 }], GradingKey = GradingService.DefaultKey1To6() };
|
||||
var vm = new ExamGradingDialogViewModel(new FakeResults(), students, memberships,
|
||||
new GradingService(), exam, group.Id);
|
||||
|
||||
vm.Rows[0].Absent = true;
|
||||
vm.MarkRemainingAbsentCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.Rows[0].Absent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt 1.6 (Klausuren-Hauptseite) ab: Priorisierung end-to-end über die ViewModel-Schicht,
|
||||
/// Parallelkurs-Umschalter (1.6.4) und Genehmigung/Ankündigung (1.6.5).
|
||||
public sealed class ExamsOverviewViewModelTests
|
||||
{
|
||||
private static ExamsOverviewViewModel BuildVm(List<LearningGroup> groups, List<Exam> exams,
|
||||
FakeMemberships? memberships = null, FakeResults? results = null) =>
|
||||
new(new FakeExams(exams), results ?? new FakeResults(), new FakeGroups(groups),
|
||||
memberships ?? new FakeMemberships([]), new GradingService(), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void Load_UnbearbeiteteKorrekturStehtVorGeplanterKlausur()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var overdue = new Exam { GroupId = group.Id, Title = "Alte Klausur", Status = ExamStatus.Conducted, Date = today.AddDays(-10) };
|
||||
var planned = new Exam { GroupId = group.Id, Title = "Neue Klausur", Status = ExamStatus.Planned, Date = today.AddDays(3) };
|
||||
|
||||
var vm = BuildVm([group], [overdue, planned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
Assert.Equal("Alte Klausur", vm.Rows[0].Title);
|
||||
Assert.Equal("Neue Klausur", vm.Rows[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeereListe_BietetAbsprungZuLerngruppen()
|
||||
{
|
||||
var vm = BuildVm([], []);
|
||||
var navigated = false;
|
||||
vm.OnNavigateToGroups = () => navigated = true;
|
||||
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.GoToGroupsCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.HasNoExams);
|
||||
Assert.True(navigated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ParallelkurseWerdenAlsGeschwisterErkannt()
|
||||
{
|
||||
var groupA = new LearningGroup { Name = "Chemie GK Q1", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var groupB = new LearningGroup { Name = "Chemie GK Q1b", SchoolYear = groupA.SchoolYear };
|
||||
var original = new Exam { GroupId = groupA.Id, Title = "Redox", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(5) };
|
||||
var duplicate = new Exam
|
||||
{
|
||||
GroupId = groupB.Id, Title = "Redox", Status = ExamStatus.Planned,
|
||||
Date = DateOnly.FromDateTime(DateTime.Today).AddDays(6), SharedExamGroupId = original.Id,
|
||||
};
|
||||
|
||||
var vm = BuildVm([groupA, groupB], [original, duplicate]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == original.Id);
|
||||
Assert.True(vm.SelectedRow.HasSibling);
|
||||
Assert.Single(vm.Siblings);
|
||||
Assert.Equal(duplicate.Id, vm.Siblings[0].Exam.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleApproval_SetztUndLöschtDatum()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Klausur", Status = ExamStatus.Planned, Date = DateOnly.FromDateTime(DateTime.Today).AddDays(2) };
|
||||
var vm = BuildVm([group], [exam]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
vm.SelectedRow = vm.Rows[0];
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.NotNull(exam.ApprovalGrantedAt);
|
||||
Assert.StartsWith("Genehmigt am", vm.ApprovalLabel);
|
||||
|
||||
vm.ToggleApprovalCommand.Execute(null);
|
||||
Assert.Null(exam.ApprovalGrantedAt);
|
||||
Assert.Equal("Genehmigung noch ausstehend", vm.ApprovalLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selection_ZeigtKorrekturfortschrittOderNotenspiegelJeNachStatus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie", SchoolYear = new SchoolYearService().CurrentSchoolYear() };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var conducted = new Exam { GroupId = group.Id, Title = "Läuft", Status = ExamStatus.Conducted, Date = today.AddDays(-2) };
|
||||
var returned = new Exam { GroupId = group.Id, Title = "Fertig", Status = ExamStatus.Returned, Date = today.AddDays(-20) };
|
||||
var vm = BuildVm([group], [conducted, returned]);
|
||||
vm.LoadCommand.Execute(null);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == conducted.Id);
|
||||
Assert.True(vm.ShowCorrectionProgress);
|
||||
Assert.False(vm.ShowGradeSummary);
|
||||
|
||||
vm.SelectedRow = vm.Rows.Single(r => r.Exam.Id == returned.Id);
|
||||
Assert.False(vm.ShowCorrectionProgress);
|
||||
Assert.True(vm.ShowGradeSummary);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -31,6 +32,17 @@ public static class TestSupport
|
||||
new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static McpSettingsService BuildMcpSettingsService()
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpsettings-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
return new McpSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Kein Konstruktorparameter - Pfade werden intern über Environment.SpecialFolder aufgelöst.
|
||||
public static McpClientRegistrationService BuildMcpClientRegistrationService() => new();
|
||||
|
||||
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
|
||||
public static WebUntisSettingsService BuildWebUntisSettingsService()
|
||||
{
|
||||
@@ -39,6 +51,14 @@ public static class TestSupport
|
||||
return new WebUntisSettingsService(tempPath);
|
||||
}
|
||||
|
||||
/// Nicht konfiguriert (kein API-Login hinterlegt) - genügt für Tests, die nur den Konstruktor
|
||||
/// bedienen müssen und keinen echten WebUntis-Zugriff auslösen.
|
||||
public static WebUntisIntegrationService BuildWebUntisIntegrationService() =>
|
||||
new(new HttpClient(), BuildWebUntisSettingsService());
|
||||
|
||||
public static UntisHubService BuildUntisHubService(List<LearningGroup>? groups = null) =>
|
||||
new(new FakeGroups(groups ?? []), new FakeUntisHubJobStates(), new SchoolYearService());
|
||||
|
||||
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
|
||||
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
|
||||
{
|
||||
@@ -170,6 +190,7 @@ public class FakeSessions(List<ParticipationSession> all) : IParticipationSessio
|
||||
{
|
||||
public List<ParticipationSession> GetByGroup(Guid groupId) => all.Where(s => s.GroupId == groupId).ToList();
|
||||
public ParticipationSession? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||
public List<ParticipationSession> GetAll() => all.ToList();
|
||||
public void Save(ParticipationSession session)
|
||||
{
|
||||
all.RemoveAll(s => s.Id == session.Id);
|
||||
@@ -337,6 +358,7 @@ public class FakeLessons : ILessonRepository
|
||||
{
|
||||
private readonly List<Lesson> _all = [];
|
||||
public void Add(Lesson l) => _all.Add(l);
|
||||
public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id);
|
||||
public List<Lesson> GetByUnit(Guid unitId) =>
|
||||
_all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList();
|
||||
public List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date) =>
|
||||
@@ -351,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
|
||||
{
|
||||
public List<Subject> GetAll() => all;
|
||||
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
|
||||
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name);
|
||||
public void Save(Subject subject) { }
|
||||
public void Delete(Guid id) { }
|
||||
public Subject? GetByName(string name) => all.FirstOrDefault(s =>
|
||||
string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
public void Save(Subject subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein.");
|
||||
var duplicate = GetByName(subject.Name);
|
||||
if (duplicate is not null && duplicate.Id != subject.Id)
|
||||
throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits.");
|
||||
all.RemoveAll(s => s.Id == subject.Id);
|
||||
all.Add(subject);
|
||||
}
|
||||
public void Delete(Guid id) => all.RemoveAll(s => s.Id == id);
|
||||
}
|
||||
|
||||
public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||
@@ -363,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
|
||||
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
|
||||
.OrderBy(d => d.SortOrder).ToList();
|
||||
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
|
||||
_all.Where(d => d.SubjectId == subjectId)
|
||||
.OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).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);
|
||||
@@ -474,6 +508,69 @@ public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
|
||||
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
|
||||
}
|
||||
|
||||
public class FakeUntisAbsenceCache : IUntisAbsenceCacheRepository
|
||||
{
|
||||
private readonly List<UntisAbsenceCacheEntry> _all = [];
|
||||
public List<UntisAbsenceCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisAbsenceCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisAbsenceCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisClassRegisterCache : IUntisClassRegisterCacheRepository
|
||||
{
|
||||
private readonly List<UntisClassRegisterCacheEntry> _all = [];
|
||||
public List<UntisClassRegisterCacheEntry> GetByClassAndRange(string className, int startDate, int endDate) =>
|
||||
_all.Where(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate).ToList();
|
||||
public void ReplaceRange(string className, int startDate, int endDate, IEnumerable<UntisClassRegisterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className && e.Date >= startDate && e.Date <= endDate);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
public void InsertRange(IEnumerable<UntisClassRegisterCacheEntry> entries) => _all.AddRange(entries);
|
||||
}
|
||||
|
||||
public class FakeUntisStudentRosterCache : IUntisStudentRosterCacheRepository
|
||||
{
|
||||
private readonly List<UntisStudentRosterCacheEntry> _all = [];
|
||||
public List<UntisStudentRosterCacheEntry> GetByClass(string className) =>
|
||||
_all.Where(e => e.ClassName == className).ToList();
|
||||
public void ReplaceAll(string className, IEnumerable<UntisStudentRosterCacheEntry> entries)
|
||||
{
|
||||
_all.RemoveAll(e => e.ClassName == className);
|
||||
_all.AddRange(entries);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeUntisCacheFetchStates : IUntisCacheFetchStateRepository
|
||||
{
|
||||
private readonly List<UntisCacheFetchState> _all = [];
|
||||
public UntisCacheFetchState? Get(string className, UntisCacheKind kind) =>
|
||||
_all.FirstOrDefault(s => s.ClassName == className && s.Kind == kind);
|
||||
public void Save(UntisCacheFetchState state)
|
||||
{
|
||||
_all.RemoveAll(s => s.ClassName == state.ClassName && s.Kind == state.Kind);
|
||||
_all.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeUntisHubJobStates : IUntisHubJobStateRepository
|
||||
{
|
||||
private readonly List<UntisHubJobState> _all = [];
|
||||
public UntisHubJobState? Get(UntisHubJobKind kind, Guid? groupId) =>
|
||||
_all.FirstOrDefault(s => s.Kind == kind && s.GroupId == groupId);
|
||||
public List<UntisHubJobState> GetAll() => _all.ToList();
|
||||
public void Save(UntisHubJobState state)
|
||||
{
|
||||
_all.RemoveAll(s => s.Kind == state.Kind && s.GroupId == state.GroupId);
|
||||
_all.Add(state);
|
||||
}
|
||||
}
|
||||
|
||||
public class FakeWorkTasks : IWorkTaskRepository
|
||||
{
|
||||
private readonly List<WorkTask> _all = [];
|
||||
@@ -514,3 +611,28 @@ 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 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 = "", bool allowSessionTrust = true)
|
||||
{
|
||||
CallCount++;
|
||||
LastTitle = title;
|
||||
LastMessage = message;
|
||||
LastOperationKey = operationKey;
|
||||
LastAllowSessionTrust = allowSessionTrust;
|
||||
return Task.FromResult(Response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Globalization;
|
||||
using LehrerApp.Desktop.Converters;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class FractionWidthConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Convert_MultipliziertTrackbreiteMitAnteil()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[200d, 0.25d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(50d, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_LiefertNullBreiteBeiUngueltigenWerten()
|
||||
{
|
||||
// Bounds.Width kann vor dem ersten Layout-Pass NaN sein (Element noch nicht gemessen) —
|
||||
// darf nicht zu einer negativen oder undefinierten Breite führen.
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
[double.NaN, 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
Assert.Equal(0d, FractionWidthConverter.Instance.Convert(
|
||||
["not a double", 0.5d], typeof(double), null, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_ClamptNegativeErgebnisseAufNull()
|
||||
{
|
||||
var result = FractionWidthConverter.Instance.Convert(
|
||||
[100d, -0.2d], typeof(double), null, CultureInfo.InvariantCulture);
|
||||
|
||||
Assert.Equal(0d, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GlobalSearchViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void LeereSuche_ZeigtSchnellaktionen()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
|
||||
Assert.Collection(vm.Results,
|
||||
item => Assert.Equal(GlobalSearchAction.NewTask, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewReminder, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewStudent, item.Action),
|
||||
item => Assert.Equal(GlobalSearchAction.NewGroupDocumentation, item.Action));
|
||||
Assert.Same(vm.Results[0], vm.SelectedResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetSchuelerGruppeKlausurUndAufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9 Chemie", SchoolYear = "2026/27", GradeLevel = 9 };
|
||||
var student = new Student { FirstName = "Mia", LastName = "Chemie" };
|
||||
var exam = new Exam { GroupId = group.Id, Title = "Chemie-Test", Date = new DateOnly(2026, 9, 1) };
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Chemie-Klausur korrigieren" });
|
||||
var vm = BuildVm([student], [group], [exam], tasks);
|
||||
|
||||
vm.Query = "Chemie";
|
||||
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Student && x.EntityId == student.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Group && x.EntityId == group.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Exam && x.EntityId == exam.Id);
|
||||
Assert.Contains(vm.Results, x => x.Kind == GlobalSearchResultKind.Task && x.Title.Contains("korrigieren"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrefferAusfuehren_NavigiertUndSchliesstPalette()
|
||||
{
|
||||
var student = new Student { FirstName = "Mia", LastName = "Muster" };
|
||||
var vm = BuildVm([student]);
|
||||
vm.Query = "Muster";
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Student);
|
||||
GlobalSearchResult? navigated = null;
|
||||
var closed = false;
|
||||
vm.OnNavigate = x => navigated = x;
|
||||
vm.OnClose = () => closed = true;
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(result);
|
||||
|
||||
Assert.Same(result, navigated);
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErinnerungSchnellErfassen_StartetDialogAlsErinnerung()
|
||||
{
|
||||
var vm = BuildVm();
|
||||
bool? reminder = null;
|
||||
vm.OnQuickAddTask = value => { reminder = value; return Task.CompletedTask; };
|
||||
var action = vm.Results.Single(x => x.Action == GlobalSearchAction.NewReminder);
|
||||
|
||||
await vm.ExecuteCommand.ExecuteAsync(action);
|
||||
|
||||
Assert.True(reminder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_FindetAktiveGruppeUeberFachUndZeigtFachImUntertitel()
|
||||
{
|
||||
var subject = new Subject { Name = "Wirtschaft-Arbeit-Technik", ShortName = "WAT" };
|
||||
var group = new LearningGroup { Name = "10c", SubjectId = subject.Id, SchoolYear = "2026/27", GradeLevel = 10 };
|
||||
var vm = BuildVm(groups: [group], subjects: [subject]);
|
||||
|
||||
vm.Query = "wat";
|
||||
|
||||
var result = Assert.Single(vm.Results, x => x.Kind == GlobalSearchResultKind.Group);
|
||||
Assert.Contains("WAT", result.Subtitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suche_UeberspringtArchivierteSchuelerUndGruppenSamtDerenKlausuren()
|
||||
{
|
||||
var archivedGroup = new LearningGroup { Name = "Testkurs Vorjahr", IsActive = false };
|
||||
var archivedStudent = new Student { FirstName = "Test", LastName = "Archiv", IsActive = false };
|
||||
var exam = new Exam { GroupId = archivedGroup.Id, Title = "Testklausur" };
|
||||
var vm = BuildVm([archivedStudent], [archivedGroup], [exam]);
|
||||
|
||||
vm.Query = "Test";
|
||||
|
||||
Assert.DoesNotContain(vm.Results, x => x.Kind is GlobalSearchResultKind.Student
|
||||
or GlobalSearchResultKind.Group or GlobalSearchResultKind.Exam);
|
||||
}
|
||||
|
||||
private static GlobalSearchViewModel BuildVm(List<Student>? students = null,
|
||||
List<LearningGroup>? groups = null, List<Exam>? exams = null, FakeWorkTasks? tasks = null,
|
||||
List<Subject>? subjects = null) =>
|
||||
new(new FakeStudents(students ?? []), new FakeGroups(groups ?? []),
|
||||
new FakeSubjects(subjects ?? []), new FakeExams(exams ?? []), tasks ?? new FakeWorkTasks());
|
||||
}
|
||||
@@ -18,8 +18,8 @@ public sealed class GroupDetailViewModelTests
|
||||
var grades = new FakeGrades();
|
||||
|
||||
var vm = new GroupDetailViewModel(groups, students, memberships, subjects, exams, grades, tasks,
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, new AttendanceBalanceService(), new SchoolYearService()),
|
||||
new GroupOverviewViewModel(new FakeLessons(), exams, new FakeResults(), new FakeSessions([]), new FakeEntries(), students,
|
||||
new FakeDocumentation(), tasks, groups, new AttendanceBalanceService(), new GradingService(), new SchoolYearService()),
|
||||
new ParticipationTabViewModel(new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
students, memberships, groups, new FakeCompetencyDomains()),
|
||||
new GradeOverviewTabViewModel(grades, exams, new FakeResults(), students, memberships, new GradingService()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class GroupDocumentationQuickViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_ErstelltPlanungFuerGesamteLerngruppe()
|
||||
{
|
||||
var group = new GroupDocumentationOption(Guid.NewGuid(), "10c · WAT");
|
||||
var vm = new GroupDocumentationQuickViewModel([group])
|
||||
{
|
||||
SelectedGroup = group, Title = "Klausur ankündigen", Content = "Termin abstimmen",
|
||||
};
|
||||
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.NotNull(vm.Result);
|
||||
Assert.Equal(Guid.Empty, vm.Result!.StudentId);
|
||||
Assert.Equal(group.Id, vm.Result.GroupId);
|
||||
Assert.Equal(DocumentationType.Planning, vm.Result.Type);
|
||||
Assert.True(vm.Result.ExcludeFromWebUntisSync);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,25 @@ public sealed class GroupDocumentationTabViewModelTests
|
||||
Assert.Equal("Alt", vm.Entries[1].Model.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ZeigtGruppenweitenEintragAuchOhneSchueler()
|
||||
{
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var docs = new FakeDocumentation();
|
||||
docs.Add(new Documentation
|
||||
{
|
||||
StudentId = Guid.Empty, GroupId = group.Id, Type = DocumentationType.Planning,
|
||||
Title = "Klausur planen", Date = new DateOnly(2026, 9, 1),
|
||||
});
|
||||
|
||||
var vm = BuildVm([], [group], docs);
|
||||
vm.Initialize(group.Id);
|
||||
|
||||
var entry = Assert.Single(vm.Entries);
|
||||
Assert.Equal("Gesamte Lerngruppe", entry.StudentName);
|
||||
Assert.Equal("Planung / Erinnerung", entry.TypeLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_EintragAusAndererGruppe_WirdMitangezeigtAberAlsFremdMarkiert()
|
||||
{
|
||||
|
||||
@@ -5,76 +5,69 @@ using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Schnellüberblick im Auswahl-Panel der Gruppenliste (Nutzer-Feedback:
|
||||
/// "oberhalb der Buttonliste ein paar Daten auswerfen. Nächste Stunde, nächste Arbeit,
|
||||
/// wichtige Todos").
|
||||
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
|
||||
public sealed class GroupListViewModelTests
|
||||
{
|
||||
private static GroupListViewModel BuildVm(LearningGroup group, FakeLessons? lessons = null,
|
||||
FakeExams? exams = null, FakeWorkTasks? tasks = null) =>
|
||||
new(new FakeGroups([group]), new FakeSubjects([]), new SchoolYearService(),
|
||||
lessons ?? new FakeLessons(), exams ?? new FakeExams([]), tasks ?? new FakeWorkTasks());
|
||||
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
|
||||
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_OhneDatenZeigtKeinenSchnellueberblick()
|
||||
public void Gruppenkarte_OeffnetDirektDieUebersicht()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
Guid? openedId = null;
|
||||
int? openedTab = null;
|
||||
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
|
||||
|
||||
vm.SelectedGroup = vm.Groups.Count > 0 ? vm.Groups[0] : new GroupListItem(group, "");
|
||||
Assert.Single(vm.Groups).OpenCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.QuickHasAnything);
|
||||
Assert.Equal(group.Id, openedId);
|
||||
Assert.Equal(0, openedTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_ZeigtNaechsteGeplanteStundeUndKlausur()
|
||||
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(3), Topic = "Redox", Status = LessonStatus.Planned });
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
||||
var vm = BuildVm(group, lessons: lessons, exams: exams);
|
||||
var first = CurrentGroup("9a");
|
||||
var second = CurrentGroup("9b");
|
||||
var vm = BuildVm(first, second);
|
||||
Guid? editedId = null;
|
||||
vm.OnEditGroup = id => { editedId = id; return Task.CompletedTask; };
|
||||
var secondItem = vm.Groups.Single(x => x.Id == second.Id);
|
||||
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
await secondItem.EditCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.True(vm.QuickHasNextLesson);
|
||||
Assert.Contains("Redox", vm.QuickNextLessonLabel);
|
||||
Assert.True(vm.QuickHasNextExam);
|
||||
Assert.Contains("Klausur 1", vm.QuickNextExamLabel);
|
||||
Assert.True(vm.QuickHasAnything);
|
||||
Assert.Equal(second.Id, editedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_ZeigtOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
|
||||
public void Suche_FiltertWeiterhinNachGruppenname()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var tasks = new FakeWorkTasks();
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
|
||||
tasks.Add(new WorkTask { GroupId = group.Id, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
|
||||
tasks.Add(new WorkTask { GroupId = Guid.NewGuid(), Title = "Andere Gruppe", DueDate = today, Status = WorkTaskStatus.Open });
|
||||
var vm = BuildVm(group, tasks: tasks);
|
||||
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
|
||||
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
vm.SearchText = "Chemie";
|
||||
|
||||
Assert.True(vm.QuickHasTasks);
|
||||
Assert.Equal("Bald fällig", Assert.Single(vm.QuickTasks).Title);
|
||||
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedGroup_Zuruecksetzen_LeertSchnellueberblick()
|
||||
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "9c" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var exams = new FakeExams([new Exam { GroupId = group.Id, Date = today.AddDays(10), Title = "Klausur 1" }]);
|
||||
var vm = BuildVm(group, exams: exams);
|
||||
vm.SelectedGroup = new GroupListItem(group, "");
|
||||
Assert.True(vm.QuickHasAnything);
|
||||
var group = CurrentGroup("9c");
|
||||
var vm = BuildVm(group);
|
||||
var item = Assert.Single(vm.Groups);
|
||||
|
||||
vm.SelectedGroup = null;
|
||||
item.ToggleArchiveCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.QuickHasAnything);
|
||||
Assert.Empty(vm.QuickTasks);
|
||||
Assert.False(group.IsActive);
|
||||
Assert.Empty(vm.Groups);
|
||||
}
|
||||
|
||||
private static LearningGroup CurrentGroup(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
|
||||
IsActive = true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,42 @@ namespace LehrerApp.Desktop.Tests;
|
||||
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
|
||||
public sealed class GroupOverviewViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnterrichtHeute_BietetNurHeutigeNichtAusgefalleneStundenUndOeffnetAuswahl()
|
||||
{
|
||||
var group = new LearningGroup { IsActive = true };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var lessons = new FakeLessons();
|
||||
var first = new Lesson { GroupId = group.Id, Date = today, Topic = "Erste Stunde" };
|
||||
var second = new Lesson { GroupId = group.Id, Date = today, Topic = "Zweite Stunde" };
|
||||
lessons.Add(first);
|
||||
lessons.Add(second);
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today, Status = LessonStatus.Cancelled });
|
||||
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(1) });
|
||||
lessons.Add(new Lesson { GroupId = Guid.NewGuid(), Date = today });
|
||||
var vm = NewVm(lessons: lessons, groups: new FakeGroups([group]));
|
||||
vm.Initialize(group.Id, group.Name);
|
||||
Assert.True(vm.HasTodayLessons);
|
||||
Assert.Equal(2, vm.TodayLessons.Count);
|
||||
Lesson? opened = null;
|
||||
vm.OnOpenTeachingMode = lesson => opened = lesson;
|
||||
vm.SelectedTeachingLesson = second;
|
||||
vm.StartTeachingModeCommand.Execute(null);
|
||||
Assert.Same(second, opened);
|
||||
group.IsActive = false;
|
||||
vm.Refresh();
|
||||
Assert.False(vm.HasTodayLessons);
|
||||
}
|
||||
|
||||
private static GroupOverviewViewModel NewVm(FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
FakeSessions? sessions = null, FakeEntries? entries = null, FakeStudents? students = null,
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), sessions ?? new FakeSessions([]),
|
||||
FakeDocumentation? documentation = null, FakeWorkTasks? tasks = null,
|
||||
FakeResults? results = null, FakeGroups? groups = null) =>
|
||||
new(lessons ?? new FakeLessons(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
sessions ?? new FakeSessions([]),
|
||||
entries ?? new FakeEntries(), students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
|
||||
tasks ?? new FakeWorkTasks(), new AttendanceBalanceService(), new SchoolYearService());
|
||||
tasks ?? new FakeWorkTasks(), groups ?? new FakeGroups([]), new AttendanceBalanceService(),
|
||||
new GradingService(), new SchoolYearService());
|
||||
|
||||
private static (GroupOverviewViewModel Vm, FakeLessons Lessons, FakeExams Exams,
|
||||
FakeSessions Sessions, FakeEntries Entries, FakeStudents Students, Guid GroupId) BuildScenario(
|
||||
@@ -76,6 +106,86 @@ public sealed class GroupOverviewViewModelTests
|
||||
Assert.Contains("Klassenarbeit 2", vm.NextExamLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneVerknuepfteVorgaengergruppe_BleibtVerborgen()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6 }]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_VorgaengerOhneKlausurergebnisse_BleibtVerborgen()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var vm = NewVm(groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.False(vm.HasYearComparison);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_ZeigtDurchschnitteBeiderJahreAn()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var currentExam = new Exam { Id = Guid.NewGuid(), GroupId = groupId };
|
||||
var exams = new FakeExams([previousExam, currentExam]);
|
||||
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "4" });
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Grade = "1" });
|
||||
// Abwesende und leere Ergebnisse fließen bewusst nicht in den Schnitt ein.
|
||||
results.Add(new ExamResult { ExamId = currentExam.Id, StudentId = Guid.NewGuid(), Absent = true, Grade = "6" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("Dieses Jahr: Ø 1,0 (1 Klausuren)", vm.YearComparisonLabel);
|
||||
Assert.Contains("Vorjahr: Ø 3,0 (2 Klausuren)", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vorjahresvergleich_OhneEigeneKlausurenZeigtNurVorjahr()
|
||||
{
|
||||
var previousId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var groups = new FakeGroups([
|
||||
new LearningGroup { Id = groupId, GradingSystem = GradingSystem.Grades1To6, PreviousGroupId = previousId },
|
||||
new LearningGroup { Id = previousId, GradingSystem = GradingSystem.Grades1To6 },
|
||||
]);
|
||||
var previousExam = new Exam { Id = Guid.NewGuid(), GroupId = previousId };
|
||||
var exams = new FakeExams([previousExam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = previousExam.Id, StudentId = Guid.NewGuid(), Grade = "3" });
|
||||
|
||||
var vm = NewVm(exams: exams, results: results, groups: groups);
|
||||
|
||||
vm.Initialize(groupId, "Testkurs");
|
||||
|
||||
Assert.True(vm.HasYearComparison);
|
||||
Assert.Contains("noch keine Klausur", vm.YearComparisonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mitarbeit_OhneSitzungenZeigtHinweisOhneWarnung()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
|
||||
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
|
||||
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
|
||||
public class LessonFixItSearchTests
|
||||
{
|
||||
[Fact]
|
||||
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
|
||||
lessons.Add(orphan);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal(orphan.Id, found.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
|
||||
lessons.Add(wrongPeriod);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal(wrongPeriod.Id, found.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
|
||||
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
|
||||
lessons.Add(near); lessons.Add(exact);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
Assert.Equal(exact.Id, candidates[0].Id);
|
||||
Assert.Equal(near.Id, candidates[1].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var otherGroupId = Guid.NewGuid();
|
||||
var unitId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
|
||||
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
|
||||
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
|
||||
// Auswahldialog, ob sie passt.
|
||||
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
var found = Assert.Single(candidates);
|
||||
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCandidates_LeerOhnePassendeStunde()
|
||||
{
|
||||
var lessons = new FakeLessons();
|
||||
var groupId = Guid.NewGuid();
|
||||
var date = new DateOnly(2026, 9, 8);
|
||||
|
||||
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||
|
||||
Assert.Empty(candidates);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LessonSchedulingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnitPicker_SchlaegtLaufendeEinheitVor_UndSpeichertNeuanlageNochNicht()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var planned = new Unit { GroupId = groupId, Title = "Später", Status = UnitStatus.Planned };
|
||||
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active };
|
||||
var repo = new FakeUnits();
|
||||
repo.Add(planned); repo.Add(active);
|
||||
var vm = new TimetableUnitPickerViewModel(repo, groupId, "10c", new(2026, 9, 2), 3);
|
||||
|
||||
Assert.Equal(active.Id, vm.SelectedUnit?.Model.Id);
|
||||
vm.NewUnitTitle = "Neue Reihe";
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.ResultIsNew);
|
||||
Assert.Equal("Neue Reihe", vm.Result?.Title);
|
||||
Assert.Null(repo.GetById(vm.Result!.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_AendertDatumUndStunde_UndRuecktNurNichtDurchgefuehrteFolgestundenNach()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 2 };
|
||||
var draftFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 3), Status = LessonStatus.Draft };
|
||||
var conducted = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Conducted };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(moved); repo.Add(draftFollowing); repo.Add(conducted);
|
||||
|
||||
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), 5, shiftFollowing: true);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 9, 8), moved.Date);
|
||||
Assert.Equal(5, moved.LessonNumber);
|
||||
Assert.Equal(new DateOnly(2026, 9, 10), draftFollowing.Date);
|
||||
Assert.Equal(new DateOnly(2026, 9, 4), conducted.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_RuecktAuchAusgefalleneFolgestundenNichtNach()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var groupId = Guid.NewGuid();
|
||||
var moved = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 1) };
|
||||
var cancelledFollowing = new Lesson { UnitId = unitId, GroupId = groupId, Date = new(2026, 9, 4), Status = LessonStatus.Cancelled };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(moved); repo.Add(cancelledFollowing);
|
||||
|
||||
new LessonSchedulingService(repo).Move(moved, new(2026, 9, 8), null, shiftFollowing: true);
|
||||
|
||||
Assert.Equal(new DateOnly(2026, 9, 4), cancelledFollowing.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitAndMoveSecondPart_AusgefalleneQuellstundeSetztFortsetzungAufEntwurfZurueck()
|
||||
{
|
||||
var source = new Lesson
|
||||
{
|
||||
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||
Status = LessonStatus.Cancelled,
|
||||
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 60 }],
|
||||
};
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source);
|
||||
|
||||
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 30,
|
||||
new(2026, 9, 3), 1, null);
|
||||
|
||||
Assert.Equal(LessonStatus.Draft, continuation.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitAndMoveSecondPart_TeiltAuchEineUeberDieGrenzeLaufendePhase()
|
||||
{
|
||||
var source = new Lesson
|
||||
{
|
||||
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(), Date = new(2026, 9, 1),
|
||||
LessonNumber = 3, Topic = "Fotosynthese", Homework = "Aufgabe 2",
|
||||
Phases =
|
||||
[
|
||||
new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Experiment", DurationMinutes = 30 },
|
||||
new LessonPhaseStep { Name = "Sicherung", DurationMinutes = 20 },
|
||||
],
|
||||
};
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source);
|
||||
|
||||
var continuation = new LessonSchedulingService(repo).SplitAndMoveSecondPart(source, 45,
|
||||
new(2026, 9, 3), 6, new TimeOnly(12, 15));
|
||||
|
||||
Assert.Equal([30, 15], source.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal([15, 20], continuation.Phases.Select(p => p.DurationMinutes));
|
||||
Assert.Equal("Fotosynthese – Fortsetzung", continuation.Topic);
|
||||
Assert.Null(source.Homework);
|
||||
Assert.Equal("Aufgabe 2", continuation.Homework);
|
||||
Assert.Equal(6, continuation.LessonNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Move_LehntBelegtenZielterminAb()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var source = new Lesson { GroupId = groupId, Date = new(2026, 9, 1), LessonNumber = 1 };
|
||||
var occupied = new Lesson { GroupId = groupId, Date = new(2026, 9, 2), LessonNumber = 4 };
|
||||
var repo = new FakeLessons();
|
||||
repo.Add(source); repo.Add(occupied);
|
||||
|
||||
var error = Assert.Throws<InvalidOperationException>(() =>
|
||||
new LessonSchedulingService(repo).Move(source, occupied.Date, 4, false));
|
||||
|
||||
Assert.Contains("bereits eine Planung", error.Message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LessonStatusDisplayTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(LessonStatus.Draft, "Entwurf")]
|
||||
[InlineData(LessonStatus.Ready, "Bereit")]
|
||||
[InlineData(LessonStatus.Conducted, "Durchgeführt")]
|
||||
[InlineData(LessonStatus.Cancelled, "Ausgefallen")]
|
||||
[InlineData(LessonStatus.Planned, "Geplant")]
|
||||
public void ToName_FromName_RoundTrip(LessonStatus status, string expectedName)
|
||||
{
|
||||
Assert.Equal(expectedName, LessonStatusDisplay.ToName(status));
|
||||
Assert.Equal(status, LessonStatusDisplay.FromName(expectedName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnitSummary_AusgefalleneStundenZaehlenWederAlsGehaltenNochAlsPensum()
|
||||
{
|
||||
var unit = new Unit { Title = "Optik" };
|
||||
var lessons = new List<Lesson>
|
||||
{
|
||||
new() { Status = LessonStatus.Conducted },
|
||||
new() { Status = LessonStatus.Cancelled },
|
||||
new() { Status = LessonStatus.Ready },
|
||||
};
|
||||
|
||||
var summary = new UnitSummary(unit, lessons);
|
||||
|
||||
Assert.Equal(2, summary.TotalCount);
|
||||
Assert.Equal(1, summary.ConductedCount);
|
||||
Assert.Equal(0.5, summary.ProgressFraction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class LetterTemplateToolsTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-lettertools-tests-{Guid.NewGuid():N}");
|
||||
public LetterTemplateToolsTests() => Directory.CreateDirectory(_directory);
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
|
||||
private LetterTemplateTools BuildTool(TemplateStore store, FakeStudents? students = null, FakeGroups? groups = null) =>
|
||||
new(store, new TemplateLoader(), new QuestTemplateRenderer(), students ?? new FakeStudents([]), groups ?? new FakeGroups([]));
|
||||
|
||||
private TemplateStore StoreWithTemplate(string name, params PlaceholderDefinition[] definitions)
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = name, Placeholders = [.. definitions] };
|
||||
var lines = new List<string> { "PAGE 210 297 mm" };
|
||||
var y = 20;
|
||||
foreach (var definition in definitions)
|
||||
{
|
||||
var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT";
|
||||
lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}");
|
||||
y += 20;
|
||||
}
|
||||
TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary<string, byte[]>());
|
||||
var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}"));
|
||||
store.Import(source);
|
||||
return store;
|
||||
}
|
||||
|
||||
private static Student StudentWithContact(string? salutation = "Sehr geehrte Frau Muster,") => new()
|
||||
{
|
||||
FirstName = "Lena", LastName = "Beispiel",
|
||||
Contacts = [new Contact
|
||||
{
|
||||
Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation,
|
||||
Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt",
|
||||
}],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ListLetterTemplates_ListetVorlageMitPlatzhaltern()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var tool = BuildTool(store);
|
||||
|
||||
var result = Assert.Single(tool.ListLetterTemplates());
|
||||
|
||||
Assert.Equal("Elternbrief", result.Name);
|
||||
Assert.Contains(result.Placeholders, p => p.Name == "Anrede" && p.Required);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_UnbekannteVorlage_LiefertFehler()
|
||||
{
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(new TemplateStore(_directory), new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter("nicht-vorhanden", student.Id, "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Base64Pdf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_UnbekannterSchueler_LiefertFehler()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var tool = BuildTool(store);
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, Guid.NewGuid(), "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_PflichtplatzhalterFehlt_LiefertFehlerOhnePdf()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief", new PlaceholderDefinition("Anrede", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact(salutation: null); // keine Anrede hinterlegt
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Anrede", result.Message);
|
||||
Assert.Null(result.Base64Pdf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_VollstaendigeDaten_LiefertBase64Pdf()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief",
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
|
||||
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Dies ist der Inhalt.", "Frau Lehrer");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Base64Pdf);
|
||||
var bytes = Convert.FromBase64String(result.Base64Pdf!);
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||
Assert.Equal("Elternbrief_Beispiel_Lena.pdf", result.SuggestedFileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderLetter_ExtraValues_FuelltZusaetzlichenPlatzhalter()
|
||||
{
|
||||
var store = StoreWithTemplate("Elternbrief",
|
||||
new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
|
||||
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
|
||||
var installed = Assert.Single(store.GetTemplates());
|
||||
var student = StudentWithContact();
|
||||
var tool = BuildTool(store, new FakeStudents([student]));
|
||||
|
||||
var result = tool.RenderLetter(installed.Id, student.Id, "Text", "Frau Lehrer",
|
||||
extraValues: new Dictionary<string, string> { ["Betreff"] = "Elternabend" });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Base64Pdf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class McpClientRegistrationServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-mcpreg-tests-{Guid.NewGuid():N}");
|
||||
public McpClientRegistrationServiceTests() => Directory.CreateDirectory(_directory);
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
|
||||
private string ConfigPath => Path.Combine(_directory, "claude_desktop_config.json");
|
||||
|
||||
private string BuildFakeBridge()
|
||||
{
|
||||
var bridgePath = Path.Combine(_directory, "LehrerApp.McpBridge.exe");
|
||||
File.WriteAllText(bridgePath, "fake");
|
||||
return bridgePath;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_OhneBridgeDatei_LiefertFehlerUndSchreibtNichts()
|
||||
{
|
||||
var service = new McpClientRegistrationService(ConfigPath, Path.Combine(_directory, "fehlt.exe"));
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(File.Exists(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_OhneBestehendeKonfiguration_LegtDateiMitEintragAn()
|
||||
{
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(service.IsRegistered());
|
||||
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_BestehendeKonfigurationMitAnderenServern_BleibtErhalten()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"}},"globalShortcut":"Ctrl+X"}""");
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.True(result.Success);
|
||||
var root = JsonNode.Parse(File.ReadAllText(ConfigPath))!;
|
||||
Assert.Equal("foo", root["mcpServers"]!["andererServer"]!["command"]!.GetValue<string>());
|
||||
Assert.Equal("Ctrl+X", root["globalShortcut"]!.GetValue<string>());
|
||||
Assert.Equal(bridgePath, root["mcpServers"]!["lehrerapp"]!["command"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_KaputteBestehendeKonfiguration_WirdNichtUeberschrieben()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, "{ das ist kein json");
|
||||
var bridgePath = BuildFakeBridge();
|
||||
var service = new McpClientRegistrationService(ConfigPath, bridgePath);
|
||||
|
||||
var result = service.Register();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("{ das ist kein json", File.ReadAllText(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unregister_EntferntNurEigenenEintrag()
|
||||
{
|
||||
File.WriteAllText(ConfigPath, """{"mcpServers":{"andererServer":{"command":"foo"},"lehrerapp":{"command":"bar"}}}""");
|
||||
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||
|
||||
var result = service.Unregister();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(service.IsRegistered());
|
||||
Assert.Contains("andererServer", File.ReadAllText(ConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistered_OhneDatei_IstFalse()
|
||||
{
|
||||
var service = new McpClientRegistrationService(ConfigPath, BuildFakeBridge());
|
||||
|
||||
Assert.False(service.IsRegistered());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,901 @@
|
||||
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_competency_catalog", "get_exams", "get_grades",
|
||||
"get_groups", "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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
|
||||
"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",
|
||||
},
|
||||
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "delete_competency_domain", "delete_lesson", "delete_subject" },
|
||||
McpToolScope.AllowedDestructiveWriteTools.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));
|
||||
}
|
||||
|
||||
// ── GroupTools ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetGroups_LiefertIdNameUndSchuljahrJeGruppe()
|
||||
{
|
||||
var group = new LearningGroup
|
||||
{
|
||||
Name = "9a", Type = GroupType.Class, SchoolYear = "2025/26", GradeLevel = 9, IsActive = true,
|
||||
};
|
||||
var tool = new GroupTools(new FakeGroups([group]));
|
||||
|
||||
var result = Assert.Single(tool.GetGroups());
|
||||
|
||||
Assert.Equal(group.Id, result.Id);
|
||||
Assert.Equal("9a", result.Name);
|
||||
Assert.Equal("2025/26", result.SchoolYear);
|
||||
Assert.Equal(9, result.GradeLevel);
|
||||
Assert.True(result.IsActive);
|
||||
}
|
||||
|
||||
// ── 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 AddLessonPhase_UebergibtEigenenMethodennamenAlsOperationKey()
|
||||
{
|
||||
// Belegt, dass IMcpConfirmationService.ConfirmAsync den [CallerMemberName]-Mechanismus
|
||||
// tatsächlich nutzt (Grundlage für die Sitzungsfreigabe "diese Aktion nicht mehr
|
||||
// nachfragen" in AvaloniaMcpConfirmationService) - ohne echtes UI testbar, weil der Name
|
||||
// vom Compiler an der Aufrufstelle in AddLessonPhase eingesetzt wird, unabhängig von der
|
||||
// konkreten IMcpConfirmationService-Implementierung.
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
await tool.AddLessonPhase(lesson.Id, "Einstieg", 10);
|
||||
|
||||
Assert.Equal(nameof(LessonPlanTools.AddLessonPhase), confirmation.LastOperationKey);
|
||||
}
|
||||
|
||||
[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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonAttachment_NutzerBestaetigt_LaedtHochUndHaengtAn()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var storage = new FakeAttachmentStorage();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage);
|
||||
var content = Convert.ToBase64String("Arbeitsblatt-Inhalt"u8.ToArray());
|
||||
|
||||
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var attachment = Assert.Single(lessons.GetById(lesson.Id)!.Attachments);
|
||||
Assert.Equal("arbeitsblatt.pdf", attachment.FileName);
|
||||
using var stream = storage.OpenRead(attachment.StorageId)!;
|
||||
using var reader = new StreamReader(stream);
|
||||
Assert.Equal("Arbeitsblatt-Inhalt", reader.ReadToEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonAttachment_NutzerLehntAb_SpeichertNichts()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
var content = Convert.ToBase64String("Inhalt"u8.ToArray());
|
||||
|
||||
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", content);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Empty(lessons.GetById(lesson.Id)!.Attachments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonAttachment_UngueltigesBase64_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.AddLessonAttachment(lesson.Id, "arbeitsblatt.pdf", "nicht-base64!!!");
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonAttachment_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||
|
||||
var result = await tool.AddLessonAttachment(Guid.NewGuid(), "x.pdf", Convert.ToBase64String("x"u8.ToArray()));
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_NutzerBestaetigt_VerschiebtDatum()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.MoveLesson(lesson.Id, new DateOnly(2026, 1, 12));
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(new DateOnly(2026, 1, 12), lessons.GetById(lesson.Id)!.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_ShiftFollowing_VerschiebtSpaetereStundenDerselbenEinheitMit()
|
||||
{
|
||||
var unitId = Guid.NewGuid();
|
||||
var moved = new Lesson { UnitId = unitId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5) };
|
||||
var later = new Lesson { UnitId = unitId, Topic = "Reflexion", Date = new DateOnly(2026, 1, 7) };
|
||||
var earlier = new Lesson { UnitId = unitId, Topic = "Einstieg", Date = new DateOnly(2026, 1, 1) };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(moved); lessons.Add(later); lessons.Add(earlier);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
// moved: 5.1. -> 12.1. (Delta +7 Tage), later (7.1., danach) muss mitwandern, earlier (1.1., davor) nicht.
|
||||
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), shiftFollowingLessons: true);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(new DateOnly(2026, 1, 14), lessons.GetById(later.Id)!.Date);
|
||||
Assert.Equal(new DateOnly(2026, 1, 1), lessons.GetById(earlier.Id)!.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_ZielStundeBelegt_LiefertFehlerNachBestaetigung()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var moved = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2026, 1, 5), LessonNumber = 1 };
|
||||
var occupying = new Lesson { GroupId = groupId, Topic = "Anderer Kurs", Date = new DateOnly(2026, 1, 12), LessonNumber = 1 };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(moved); lessons.Add(occupying);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.MoveLesson(moved.Id, new DateOnly(2026, 1, 12), newPeriod: 1);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(1, confirmation.CallCount); // Bestätigung lief bereits, Konflikt zeigt sich erst beim Speichern.
|
||||
Assert.Equal(new DateOnly(2026, 1, 5), lessons.GetById(moved.Id)!.Date); // unverändert
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||
|
||||
var result = await tool.MoveLesson(Guid.NewGuid(), new DateOnly(2026, 1, 12));
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLesson_NutzerBestaetigt_LoeschtEndgueltig()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.DeleteLesson(lesson.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Null(lessons.GetById(lesson.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLesson_NutzerLehntAb_BleibtErhalten()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.DeleteLesson(lesson.Id);
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.NotNull(lessons.GetById(lesson.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteLesson_UnbekannteStunde_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(confirmation: confirmation);
|
||||
|
||||
var result = await tool.DeleteLesson(Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// ── LessonPlanTools — Kompetenzzuordnung ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Competencies.Add("PH.9.1");
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
Assert.Single(lessons.GetById(lesson.Id)!.Competencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
lesson.Competencies.Add("PH.9.1");
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var tool = BuildLessonPlanTools(lessons: lessons);
|
||||
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Empty(lessons.GetById(lesson.Id)!.Competencies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Brechung" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation);
|
||||
|
||||
var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1");
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
// ── CompetencyTools ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetSubjects_LiefertAlleFaecher()
|
||||
{
|
||||
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||
var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||
|
||||
var dto = Assert.Single(tool.GetSubjects());
|
||||
|
||||
Assert.Equal("Mathematik", dto.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSubject_NutzerBestaetigt_SpeichertFach()
|
||||
{
|
||||
var subjects = new FakeSubjects([]);
|
||||
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.CreateSubject("Mathematik", "Ma");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var subject = Assert.Single(subjects.GetAll());
|
||||
Assert.Equal("Mathematik", subject.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSubject_NameBereitsVergeben_LiefertFehler()
|
||||
{
|
||||
var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]);
|
||||
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.CreateSubject("Mathematik");
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Single(subjects.GetAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSubject_AendertNurAngegebeneFelder()
|
||||
{
|
||||
var subject = new Subject { Name = "Mathematik", ShortName = "Ma" };
|
||||
var subjects = new FakeSubjects([subject]);
|
||||
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.UpdateSubject(subject.Id, shortName: "M");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var updated = subjects.GetById(subject.Id)!;
|
||||
Assert.Equal("Mathematik", updated.Name);
|
||||
Assert.Equal("M", updated.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach()
|
||||
{
|
||||
var subject = new Subject { Name = "Mathematik" };
|
||||
var subjects = new FakeSubjects([subject]);
|
||||
var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.DeleteSubject(subject.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Empty(subjects.GetAll());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage()
|
||||
{
|
||||
var confirmation = new FakeMcpConfirmation();
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation);
|
||||
|
||||
var result = await tool.DeleteSubject(Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Applied);
|
||||
Assert.Equal(0, confirmation.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs()
|
||||
{
|
||||
var subjectId = Guid.NewGuid();
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" });
|
||||
domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" });
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = tool.GetCompetencyCatalog(subjectId);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich()
|
||||
{
|
||||
var subject = new Subject { Name = "Mathematik" };
|
||||
var domains = new FakeCompetencyDomains();
|
||||
var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9));
|
||||
Assert.Equal("Funktionen", domain.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems()
|
||||
{
|
||||
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||
domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." });
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(domain);
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.DeleteCompetencyDomain(domain.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Null(domains.GetById(domain.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn()
|
||||
{
|
||||
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(domain);
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var item = Assert.Single(domains.GetById(domain.Id)!.Items);
|
||||
Assert.Equal("M.9.1", item.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder()
|
||||
{
|
||||
var item = new CompetencyItem { Code = "M.9.1", Description = "alt" };
|
||||
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||
domain.Items.Add(item);
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(domain);
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu");
|
||||
|
||||
Assert.True(result.Applied);
|
||||
var updated = domains.GetById(domain.Id)!.Items.Single();
|
||||
Assert.Equal("M.9.1", updated.Code);
|
||||
Assert.Equal("neu", updated.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem()
|
||||
{
|
||||
var item = new CompetencyItem { Code = "M.9.1", Description = "..." };
|
||||
var domain = new CompetencyDomain { Name = "Funktionen" };
|
||||
domain.Items.Add(item);
|
||||
var domains = new FakeCompetencyDomains();
|
||||
domains.Add(domain);
|
||||
var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation());
|
||||
|
||||
var result = await tool.RemoveCompetencyItem(domain.Id, item.Id);
|
||||
|
||||
Assert.True(result.Applied);
|
||||
Assert.Empty(domains.GetById(domain.Id)!.Items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class PersonalizeWorksheetDialogViewModelTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-worksheet-vm-tests-{Guid.NewGuid():N}");
|
||||
public PersonalizeWorksheetDialogViewModelTests() => Directory.CreateDirectory(_directory);
|
||||
|
||||
[Fact]
|
||||
public void Generate_ErzeugtEinePdfJeAusgewaehltemSchueler()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||
var vm = Build(group, [anna, ben], StoreWithTemplate());
|
||||
var output = Path.Combine(_directory, "output");
|
||||
|
||||
vm.Generate(output);
|
||||
|
||||
Assert.Equal(2, vm.Results.Count);
|
||||
Assert.All(vm.Results, r => Assert.True(r.Success));
|
||||
Assert.Equal(2, Directory.GetFiles(output, "*.pdf").Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_AbgewaehlterSchueler_WirdUebersprungen()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||
var vm = Build(group, [anna, ben], StoreWithTemplate());
|
||||
vm.Students.Single(s => s.Model.Id == ben.Id).IsIncluded = false;
|
||||
var output = Path.Combine(_directory, "output");
|
||||
|
||||
vm.Generate(output);
|
||||
|
||||
Assert.Single(vm.Results);
|
||||
Assert.Equal(anna.FullName, vm.Results[0].StudentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneVorlage_GenerateTutNichts()
|
||||
{
|
||||
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
|
||||
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||
var vm = Build(group, [anna], new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, "empty-store"))));
|
||||
var output = Path.Combine(_directory, "output");
|
||||
|
||||
vm.Generate(output);
|
||||
|
||||
Assert.Empty(vm.Results);
|
||||
}
|
||||
|
||||
private static PersonalizeWorksheetDialogViewModel Build(LearningGroup group, List<Student> students, WorksheetTemplateStore store) =>
|
||||
new(group, [.. students.Select(s => s.Id)], new FakeStudents(students), store, new QuestTemplateRenderer());
|
||||
|
||||
private WorksheetTemplateStore StoreWithTemplate()
|
||||
{
|
||||
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = $"arbeitsblatt-{Guid.NewGuid():N}", Name = "Arbeitsblatt",
|
||||
Placeholders = [new PlaceholderDefinition("Student.FirstName", PlaceholderType.Text)],
|
||||
};
|
||||
TemplatePackage.Create(source, manifest, "PAGE 210 297 mm\nTEXT 20 20 $Student.FirstName", new Dictionary<string, byte[]>());
|
||||
var store = new WorksheetTemplateStore(new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")));
|
||||
store.Store.Import(source);
|
||||
return store;
|
||||
}
|
||||
|
||||
public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); }
|
||||
}
|
||||
@@ -45,6 +45,21 @@ public class PlanningTabViewModelTests
|
||||
Assert.Contains("2 / 3", summary.ProgressText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_WaehltLaufendeEinheitAlsArbeitskontext()
|
||||
{
|
||||
var (vm, units, _, groupId) = BuildScenario();
|
||||
units.Add(new Unit { GroupId = groupId, Title = "Früher", Status = UnitStatus.Completed,
|
||||
StartDate = new DateOnly(2025, 8, 1) });
|
||||
var active = new Unit { GroupId = groupId, Title = "Aktuell", Status = UnitStatus.Active,
|
||||
StartDate = new DateOnly(2025, 9, 1) };
|
||||
units.Add(active);
|
||||
|
||||
vm.Initialize(groupId);
|
||||
|
||||
Assert.Equal(active.Id, vm.SelectedUnit?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLesson_MitNachrueckenVerschiebtNurGeplanteFolgestundenNachDemStichtag()
|
||||
{
|
||||
@@ -93,6 +108,30 @@ public class PlanningTabViewModelTests
|
||||
Assert.Equal(new DateOnly(2025, 9, 8), byUnit[l2.Id].Date); // unverändert
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeLessonUnit_OrdnetStundeDerZieleinheitZuUndWaehltSieAus()
|
||||
{
|
||||
var (vm, units, lessons, groupId) = BuildScenario();
|
||||
var unitA = new Unit { GroupId = groupId, Title = "Mechanik" };
|
||||
var unitB = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unitA); units.Add(unitB);
|
||||
var l1 = new Lesson { UnitId = unitA.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Status = LessonStatus.Planned, Topic = "1" };
|
||||
lessons.Add(l1);
|
||||
|
||||
vm.Initialize(groupId);
|
||||
vm.SelectedUnit = vm.Units.Single(u => u.Id == unitA.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == l1.Id);
|
||||
vm.OnPickUnitChange = _ => Task.FromResult<Unit?>(unitB);
|
||||
|
||||
await vm.ChangeLessonUnitCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Empty(lessons.GetByUnit(unitA.Id));
|
||||
var moved = Assert.Single(lessons.GetByUnit(unitB.Id));
|
||||
Assert.Equal(l1.Id, moved.Id);
|
||||
Assert.Equal(unitB.Id, vm.SelectedUnit?.Id);
|
||||
Assert.Equal(l1.Id, vm.SelectedLesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyUnit_ErhaeltAbstaendeUndSetztGroupIdAufZielgruppe()
|
||||
{
|
||||
@@ -294,4 +333,44 @@ public class PlanningTabViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal("Für diese Stunde existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateParticipationSession_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. Vertretung) soll die bereits
|
||||
// bestehende Sitzung des Tages weiterverwenden statt eine zweite anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var group = new LearningGroup { Id = groupId, Name = "Testgruppe" };
|
||||
var groups = new FakeGroups([group]);
|
||||
var units = new FakeUnits();
|
||||
var lessons = new FakeLessons();
|
||||
var sessions = new FakeSessions([]);
|
||||
var vm = new PlanningTabViewModel(units, lessons, groups, new FakeSubjects([]),
|
||||
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), sessions);
|
||||
vm.Initialize(groupId);
|
||||
|
||||
var unit = new Unit { GroupId = groupId, Title = "Optik" };
|
||||
units.Add(unit);
|
||||
var date = new DateOnly(2025, 9, 1);
|
||||
var doubleLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Brechung" };
|
||||
var thirdLesson = new Lesson
|
||||
{ UnitId = unit.Id, GroupId = groupId, Date = date, Topic = "Vertretung" };
|
||||
lessons.Add(doubleLesson);
|
||||
lessons.Add(thirdLesson);
|
||||
|
||||
vm.RefreshPlanning(unit.Id, doubleLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == doubleLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
string? notified = null;
|
||||
vm.OnNotify = m => notified = m;
|
||||
vm.RefreshPlanning(unit.Id, thirdLesson.Id);
|
||||
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == thirdLesson.Id);
|
||||
vm.CreateParticipationSessionCommand.Execute(null);
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(doubleLesson.Id, created.LessonId);
|
||||
Assert.Equal("Für diesen Tag existiert bereits eine Sitzung.", notified);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,67 @@ public sealed class QuickInputViewModelTests
|
||||
Assert.Equal(0.4, vm.CurrentStudentContentOpacity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearRating_SetztVersehentlichGesetzteBewertungAufNichtBewertetZurueck()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
vm.SetRatingByNumber(4); // aus Versehen eine Note für Qualität vergeben
|
||||
|
||||
vm.ClearRating();
|
||||
|
||||
Assert.Null(vm.AspectRows[0].Value);
|
||||
Assert.Null(rows[0].GetRating("quality"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDayHighlight_SpeichertAufDerZeileUndAktualisiertDieAnzeige()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Standout);
|
||||
|
||||
Assert.Equal(DayHighlightKind.Standout, rows[0].DayHighlight);
|
||||
Assert.Equal("👑", vm.CurrentStudentDayHighlightSymbol);
|
||||
Assert.Equal("Spitzentag", vm.CurrentStudentDayHighlightLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZurueckZuVorherigemSchueler_ZeigtDessenTagesflaggeWiederAn()
|
||||
{
|
||||
var aspects = new List<AspectColumnDef>
|
||||
{
|
||||
new(new ParticipationAspect { Key = "quality", Label = "Qualität" }),
|
||||
};
|
||||
var rows = new List<ParticipationStudentRow>
|
||||
{
|
||||
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
|
||||
new(Guid.NewGuid(), "Ben", new ParticipationEntry(), aspects, []),
|
||||
};
|
||||
var vm = new QuickInputViewModel(rows, aspects);
|
||||
|
||||
vm.SetDayHighlight(DayHighlightKind.Sleepy); // Anna
|
||||
vm.NextStudent(); // zu Ben, keine Flagge
|
||||
vm.PreviousStudent(); // zurück zu Anna
|
||||
|
||||
Assert.Equal(DayHighlightKind.Sleepy, vm.CurrentStudentDayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VorherigerAspekt_SpringtRueckwaertsMitUmlauf()
|
||||
{
|
||||
|
||||
@@ -256,6 +256,30 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_AndereStundeAmSelbenTagBereitsVerknuepft_LegtKeineZweiteSitzungAn()
|
||||
{
|
||||
// Nutzer-Feedback: eine dritte Stunde am selben Tag (z.B. durch Vertretung, eigene Lesson-
|
||||
// Id) soll die bereits bestehende Sitzung der Doppelstunde weiterverwenden statt eine
|
||||
// zweite, unabhängige Sitzung für denselben Tag anzulegen.
|
||||
var groupId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var doubleLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Redox" };
|
||||
var thirdLesson = new Lesson { GroupId = groupId, Date = today, Topic = "Vertretung" };
|
||||
var existingSession = new ParticipationSession
|
||||
{ GroupId = groupId, Date = today, LessonId = doubleLesson.Id, Comment = "Redox" };
|
||||
var sessions = new FakeSessions([existingSession]);
|
||||
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([]),
|
||||
new FakeMemberships([]), sessions, new FakeEntries(), new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
|
||||
vm.SelectOrCreateSessionForLesson(thirdLesson);
|
||||
|
||||
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
|
||||
Assert.Single(sessions.GetByGroup(groupId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
|
||||
{
|
||||
@@ -472,6 +496,7 @@ public sealed class SeatingPlanViewModelTests
|
||||
vm.SetRatingByNumber(5);
|
||||
vm.ApplyAttendanceShortcut(1, clear: false);
|
||||
vm.ApplyHomeworkShortcut(7, clear: false);
|
||||
vm.ApplyDayHighlightShortcut(1, clear: false);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
|
||||
@@ -481,6 +506,56 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||
Assert.True(entry.HomeworkMissing);
|
||||
Assert.Equal(DayHighlightKind.Standout, entry.DayHighlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SitzplatzBewertung_TagesflaggeKannWiederGeloeschtWerden()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||
|
||||
vm.ApplyDayHighlightShortcut(3, clear: false); // aus Versehen "Schlechter Tag" gesetzt
|
||||
vm.ApplyDayHighlightShortcut(null, clear: true);
|
||||
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||
Assert.Null(entry.DayHighlight);
|
||||
Assert.Equal("Keine Markierung", vm.DayHighlightLabel);
|
||||
Assert.All(vm.DayHighlightChoices, c => Assert.Equal(c.Kind is null, c.IsSelected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SitzplatzBewertung_SchlaegtQuantitaetAusStrichlisteVor()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var studentId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var session = new ParticipationSession { GroupId = groupId, Date = today };
|
||||
var sessions = new FakeSessions([session]);
|
||||
var entries = new FakeEntries();
|
||||
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = studentId, RaisedHandCount = 3 });
|
||||
|
||||
var vm = new SeatAssessmentViewModel(sessions, entries, new FakeAspects(),
|
||||
groupId, studentId, "Beispiel, Anna", canEdit: true);
|
||||
|
||||
var quantityRow = vm.AspectRows.Single(r => r.Key == "quantity");
|
||||
Assert.True(quantityRow.HasSuggestion);
|
||||
Assert.Equal(0, quantityRow.SuggestedValue); // 3 gemeldet -> "∼" laut ParticipationCountSuggestion
|
||||
Assert.Contains("3× gemeldet", quantityRow.SuggestionLabel);
|
||||
|
||||
var qualityRow = vm.AspectRows.Single(r => r.Key == "quality");
|
||||
Assert.False(qualityRow.HasSuggestion);
|
||||
|
||||
quantityRow.ApplySuggestionCommand.Execute(null);
|
||||
|
||||
Assert.Equal(0, quantityRow.Value);
|
||||
var updatedEntry = entries.GetBySessionAndStudent(session.Id, studentId)!;
|
||||
Assert.Equal(0, updatedEntry.Ratings.Single(r => r.Key == "quantity").Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -517,6 +592,60 @@ public sealed class SeatingPlanViewModelTests
|
||||
Assert.Null(vm.SelectedSession);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TallyRaisedHand_ErhoehtNurGemeldetZaehler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
sessions, entries, new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
var seat = vm.Seats.Single();
|
||||
|
||||
seat.TallyRaisedHandCommand.Execute(null);
|
||||
seat.TallyRaisedHandCommand.Execute(null);
|
||||
|
||||
Assert.Equal(2, seat.RaisedHandCount);
|
||||
Assert.Equal(0, seat.CalledOnCount);
|
||||
var session = Assert.Single(sessions.GetByGroup(groupId));
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||
Assert.Equal(2, entry.RaisedHandCount);
|
||||
Assert.Equal(0, entry.CalledOnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TallyCalledOn_ErhoehtBeideZaehler()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "A" };
|
||||
var plan = new SeatingPlan
|
||||
{
|
||||
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
|
||||
Assignments = [new SeatAssignment { StudentId = student.Id }],
|
||||
};
|
||||
var sessions = new FakeSessions([]);
|
||||
var entries = new FakeEntries();
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
|
||||
sessions, entries, new FakeAspects());
|
||||
vm.Initialize(groupId, isReadOnly: false);
|
||||
var seat = vm.Seats.Single();
|
||||
|
||||
seat.TallyRaisedHandCommand.Execute(null); // erste Meldung, nicht drangekommen
|
||||
seat.TallyCalledOnCommand.Execute(null); // zweite Meldung, drangekommen
|
||||
|
||||
Assert.Equal(2, seat.RaisedHandCount);
|
||||
Assert.Equal(1, seat.CalledOnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssessStudent_LegtErstBeiTatsaechlicherBewertungEineSitzungAn()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class SeatingQuickCheckTests
|
||||
{
|
||||
private static (SeatingPlanTabViewModel Vm, FakeEntries Entries, ParticipationSession Session, Student Student) Build(bool readOnly = false)
|
||||
{
|
||||
var group = Guid.NewGuid();
|
||||
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
|
||||
var session = new ParticipationSession { GroupId = group, Date = DateOnly.FromDateTime(DateTime.Today) };
|
||||
var entries = new FakeEntries();
|
||||
var plan = new SeatingPlan { GroupId = group, Rows = 1, Columns = 2,
|
||||
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }] };
|
||||
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
|
||||
new FakeMemberships([new GroupMembership { GroupId = group, StudentId = student.Id }]),
|
||||
new FakeSessions([session]), entries, new FakeAspects());
|
||||
vm.Initialize(group, readOnly);
|
||||
return (vm, entries, session, student);
|
||||
}
|
||||
|
||||
[Fact] public void Attendance_UsesSeatAndPreservesExistingHomeworkAndCounters()
|
||||
{
|
||||
var (vm, entries, session, student) = Build();
|
||||
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id,
|
||||
Homework = HomeworkStatus.Completed, RaisedHandCount = 3, CalledOnCount = 2 });
|
||||
vm.CheckAttendanceCommand.Execute(null);
|
||||
var seat = vm.Seats[0];
|
||||
Assert.True(seat.ShowQuickCheck);
|
||||
Assert.False(vm.Seats[1].ShowQuickCheck);
|
||||
seat.QuickNegativeCommand.Execute(null);
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||
Assert.Equal(AttendanceStatus.ExcusePending, entry.Attendance);
|
||||
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
|
||||
Assert.Equal(3, entry.RaisedHandCount);
|
||||
Assert.Equal(2, entry.CalledOnCount);
|
||||
Assert.Equal(1, seat.DisplayOpacity);
|
||||
seat.QuickPositiveCommand.Execute(null);
|
||||
Assert.Equal(AttendanceStatus.Present, entry.Attendance);
|
||||
}
|
||||
|
||||
[Fact] public void Homework_UpdatesLegacyFlagAndPreservesAttendance()
|
||||
{
|
||||
var (vm, entries, session, student) = Build();
|
||||
entries.Save(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Late });
|
||||
vm.CheckHomeworkCommand.Execute(null);
|
||||
vm.Seats[0].QuickNegativeCommand.Execute(null);
|
||||
var entry = entries.GetBySessionAndStudent(session.Id, student.Id)!;
|
||||
Assert.Equal(HomeworkStatus.MissingOpen, entry.Homework);
|
||||
Assert.True(entry.HomeworkMissing);
|
||||
Assert.Equal(AttendanceStatus.Late, entry.Attendance);
|
||||
vm.Seats[0].QuickPositiveCommand.Execute(null);
|
||||
Assert.Equal(HomeworkStatus.Completed, entry.Homework);
|
||||
Assert.False(entry.HomeworkMissing);
|
||||
vm.EndQuickCheckCommand.Execute(null);
|
||||
Assert.False(vm.Seats[0].ShowQuickCheck);
|
||||
Assert.True(vm.Seats[0].ShowNormalActions);
|
||||
}
|
||||
|
||||
[Fact] public async Task SpecialCases_OpenAssessmentForClickedStudentAndSession()
|
||||
{
|
||||
var (vm, _, _, _) = Build();
|
||||
var calls = 0;
|
||||
vm.OnAssessStudent = _ => { calls++; return Task.CompletedTask; };
|
||||
vm.CheckAttendanceCommand.Execute(null);
|
||||
await vm.Seats[0].QuickSpecialCommand.ExecuteAsync(null);
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact] public void ReadOnlyAndEmptySeats_CannotWriteQuickChecks()
|
||||
{
|
||||
var (vm, entries, session, _) = Build(readOnly: true);
|
||||
vm.CheckHomeworkCommand.Execute(null);
|
||||
vm.Seats[0].QuickNegativeCommand.Execute(null);
|
||||
vm.Seats[1].QuickPositiveCommand.Execute(null);
|
||||
Assert.False(vm.Seats[0].ShowQuickCheck);
|
||||
Assert.Empty(entries.GetBySession(session.Id));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Models;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
@@ -28,12 +29,15 @@ public sealed class SettingsViewModelTests
|
||||
return new SettingsViewModel(
|
||||
subjects ?? new FakeSubjects([]), competencyDomains ?? new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath),
|
||||
new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(),
|
||||
new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
|
||||
@@ -217,6 +221,25 @@ public sealed class SettingsViewModelTests
|
||||
Assert.Empty(queue.GetUnreviewed());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClearSyncConflicts_LeertProtokollUndQueue()
|
||||
{
|
||||
var queue = TestSupport.BuildEventQueue();
|
||||
foreach (var resolution in new[] { "LocalWon", "RemoteWon" })
|
||||
queue.AddConflict(new ConflictEntry
|
||||
{
|
||||
LocalEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||
RemoteEvent = new SyncEvent { EntityType = "Student", EntityId = Guid.NewGuid().ToString() },
|
||||
Resolution = resolution,
|
||||
});
|
||||
var vm = BuildViewModel(eventQueue: queue);
|
||||
|
||||
vm.ClearSyncConflictsCommand.Execute(null);
|
||||
|
||||
Assert.Empty(vm.SyncConflicts);
|
||||
Assert.Empty(queue.GetUnreviewed());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddSchoolHoliday_GueltigeEingabe_WirdGespeichertUndInListeAngezeigt()
|
||||
{
|
||||
@@ -314,11 +337,14 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath),
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -341,11 +367,14 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
@@ -372,11 +401,14 @@ public sealed class SettingsViewModelTests
|
||||
var vm = new SettingsViewModel(
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeGradingKeyTemplates(),
|
||||
new FakeSchemes(), new GradingService(), new BackupService(tempPath),
|
||||
new BackupSettingsService(tempPath),
|
||||
new DatabaseEncryptionService(), new AppLockService(tempPath),
|
||||
new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath),
|
||||
new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]),
|
||||
new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule,
|
||||
new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
new FakeSupervisionDuties(), new TemplateStore(tempPath), new WorksheetTemplateStore(new TemplateStore(tempPath, subfolder: "worksheet-template-packages")), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
|
||||
TestSupport.BuildMcpSettingsService(),
|
||||
TestSupport.BuildMcpClientRegistrationService(),
|
||||
TestSupport.BuildWebUntisSettingsService(),
|
||||
TestSupport.BuildAnnualPlanSettingsService(),
|
||||
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
|
||||
|
||||
@@ -51,6 +51,18 @@ public class StudentListViewModelTests
|
||||
Assert.Equal("M", vm.Students.Single(s => s.Id == _ben.Id).AvatarLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SucheOhneTreffer_LiefertHilfreichenLeerzustand()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
vm.SearchText = "nicht vorhanden";
|
||||
|
||||
Assert.True(vm.HasNoStudents);
|
||||
Assert.False(vm.HasStudents);
|
||||
Assert.Contains("Suche", vm.EmptyListMessage);
|
||||
}
|
||||
|
||||
private StudentListViewModel BuildViewModel() => new(
|
||||
new FakeStudents([_anna, _ben]),
|
||||
new FakeGroups([_group]),
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
/// Tests für den Schüler-Leistungsüberblick (Nutzer-Feedback): Klausuren/Mitarbeit/Sonstige
|
||||
/// im Überblick, berechnete Zeugnisnote, Zielnoten- und Was-wäre-wenn-Rechner sowie der
|
||||
/// Bewerter-/Schülermodus-Unterschied (Kursdurchschnitt, Bearbeitbarkeit).
|
||||
public sealed class StudentPerformanceOverviewViewModelTests
|
||||
{
|
||||
private static readonly Guid GroupId = Guid.NewGuid();
|
||||
private static readonly Guid StudentId = Guid.NewGuid();
|
||||
private static readonly Guid OtherStudentId = Guid.NewGuid();
|
||||
|
||||
private static StudentPerformanceOverviewViewModel BuildViewModel(
|
||||
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
|
||||
FakeSchemes? schemes = null, GradingSystem system = GradingSystem.Grades1To6)
|
||||
{
|
||||
var scheme = new GradingScheme { ExamsPercent = 50, ParticipationPercent = 40, OtherPercent = 10 };
|
||||
var schemesRepo = schemes ?? new FakeSchemes();
|
||||
if (schemes is null) schemesRepo.SetForGroup(GroupId, scheme);
|
||||
|
||||
return new StudentPerformanceOverviewViewModel(
|
||||
grades ?? new FakeGrades(), exams ?? new FakeExams([]), results ?? new FakeResults(),
|
||||
new FakeMemberships([]), schemesRepo, new GradingService(),
|
||||
GroupId, StudentId, GroupType.Class, system, "Beispiel, Anna", "Testkurs", "2025/26");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamRows_ZeigtEigeneNoteUndKursdurchschnitt()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = OtherStudentId, Grade = "4" });
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
var row = Assert.Single(vm.ExamRows);
|
||||
Assert.Equal("2", row.OwnGradeDisplay);
|
||||
Assert.Equal("3.0", row.ClassAverageDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExamHistory_ZeigtEigenenVerlaufUndMarkiertNotenabfall()
|
||||
{
|
||||
var exam1 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exam2 = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K2", Date = new DateOnly(2025, 9, 24) };
|
||||
var exams = new FakeExams([exam1, exam2]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam1.Id, StudentId = StudentId, Grade = "2" });
|
||||
results.Add(new ExamResult { ExamId = exam2.Id, StudentId = StudentId, Grade = "5" }); // Abfall + mangelhaft
|
||||
|
||||
var vm = BuildViewModel(exams, results);
|
||||
|
||||
Assert.Equal(2, vm.ExamHistory.Points.Count);
|
||||
Assert.False(vm.ExamHistory.Points[0].IsWarning);
|
||||
Assert.True(vm.ExamHistory.Points[1].IsWarning);
|
||||
Assert.Contains("Abfall", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.Contains("Versetzungsgefährdung", vm.ExamHistory.Points[1].WarningText);
|
||||
Assert.True(vm.ExamHistory.Points[1].BarHeight < vm.ExamHistory.Points[0].BarHeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AktuelleZeugnisnote_KombiniertAlleDreiBereiche()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "1" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
// (2*50 + 3*40 + 1*10) / 100 = 2.3 -> kaufmännisch 2
|
||||
Assert.Equal("Note 2", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zielnote_BerechnetNoetigenKlausurschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" });
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Other,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
vm.TargetGradeText = "2";
|
||||
vm.TargetBucketName = "Klausuren";
|
||||
|
||||
// Ziel 2,0 = (x*50 + 3*40 + 2*10)/100 => 200 = 50x+120+20 => x = 1,2
|
||||
Assert.Contains("1.2", vm.TargetResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WasWaereWenn_ZeigtZeugnisnoteMitZusaetzlicherKlausurAn()
|
||||
{
|
||||
var exam = new Exam { Id = Guid.NewGuid(), GroupId = GroupId, Title = "K1", Date = new DateOnly(2025, 9, 10) };
|
||||
var exams = new FakeExams([exam]);
|
||||
var results = new FakeResults();
|
||||
results.Add(new ExamResult { ExamId = exam.Id, StudentId = StudentId, Grade = "2" });
|
||||
var grades = new FakeGrades();
|
||||
grades.Add(new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "2" });
|
||||
|
||||
var vm = BuildViewModel(exams, results, grades);
|
||||
|
||||
vm.WhatIfExamGradeText = "6";
|
||||
|
||||
// Klausuren neu: (2+6)/2=4 (50%), Mitarbeit 2 (40%, Sonstige fehlt) -> (4*50+2*40)/90 = 3,11 -> kaufm. 3
|
||||
Assert.Contains("3", vm.WhatIfResultDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCommand_AufMitarbeitszeile_SpeichertUndAktualisiertDurchschnitt()
|
||||
{
|
||||
var grades = new FakeGrades();
|
||||
var grade = new Grade { StudentId = StudentId, GroupId = GroupId, Category = GradeCategory.Participation,
|
||||
Date = new DateOnly(2025, 9, 5), Value = "3" };
|
||||
grades.Add(grade);
|
||||
var vm = BuildViewModel(grades: grades);
|
||||
|
||||
var row = Assert.Single(vm.ParticipationRows);
|
||||
row.Value = "1";
|
||||
row.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal("1.0", vm.ParticipationAverageDisplay);
|
||||
Assert.Equal("1", grades.GetByStudentAndGroup(StudentId, GroupId).Single().Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OhneWerteImZeitraum_ZeigtHinweisStattZeugnisnote()
|
||||
{
|
||||
var vm = BuildViewModel();
|
||||
|
||||
Assert.Contains("Noch nicht berechenbar", vm.CurrentReportGradeDisplay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class StudentPickerDialogViewModelTests
|
||||
{
|
||||
private static (StudentPickerDialogViewModel Vm, Student Anna, Student Ben) Build()
|
||||
{
|
||||
var anna = new Student { FirstName = "Anna", LastName = "Adler" };
|
||||
var ben = new Student { FirstName = "Ben", LastName = "Bauer" };
|
||||
var vm = new StudentPickerDialogViewModel(new FakeStudents([anna, ben]));
|
||||
return (vm, anna, ben);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchText_FiltertNachTeilstring()
|
||||
{
|
||||
var (vm, anna, _) = Build();
|
||||
|
||||
vm.SearchText = "ann";
|
||||
|
||||
Assert.Single(vm.Students);
|
||||
Assert.Equal(anna.FullName, vm.Students[0].FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_OhneAuswahl_SetztValidationMessage()
|
||||
{
|
||||
var (vm, _, _) = Build();
|
||||
|
||||
vm.SelectCommand.Execute(null);
|
||||
|
||||
Assert.Null(vm.Result);
|
||||
Assert.NotEqual("", vm.ValidationMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_MitAuswahl_LiefertVollenSchueler()
|
||||
{
|
||||
var (vm, anna, _) = Build();
|
||||
vm.SelectedStudent = vm.Students.Single(s => s.Id == anna.Id);
|
||||
|
||||
vm.SelectCommand.Execute(null);
|
||||
|
||||
Assert.Equal(anna.Id, vm.Result?.Id);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,17 @@ public sealed class TeachingModeViewModelTests
|
||||
new FakeStudents(students ?? []), new FakeMemberships(memberships ?? []),
|
||||
sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects());
|
||||
|
||||
/// Dieselben Fakes wie <see cref="BuildSeatingPlan"/>, aber für ParticipationTabViewModel —
|
||||
/// beide brauchen eigene IParticipationSessionRepository/IParticipationRepository-Instanzen
|
||||
/// im Produktivcode (siehe TeachingModeWindow-Kommentar zu ReloadSeatBadgesFromRepository),
|
||||
/// hier reicht für die Tests aber eine gemeinsame FakeSessions-Instanz, damit beide
|
||||
/// ViewModels dieselbe verknüpfte Sitzung sehen.
|
||||
private static ParticipationTabViewModel BuildParticipation(LearningGroup group,
|
||||
FakeSessions? sessions = null) =>
|
||||
new(sessions ?? new FakeSessions([]), new FakeEntries(), new FakeAspects(),
|
||||
new FakeStudents([]), new FakeMemberships([]), new FakeGroups([group]),
|
||||
new FakeCompetencyDomains());
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_LaedtVerlaufsplanUndInitialisiertSitzplanFuerDieGruppe()
|
||||
{
|
||||
@@ -24,7 +35,8 @@ public sealed class TeachingModeViewModelTests
|
||||
};
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.Equal("Q1 Chemie", vm.GroupName);
|
||||
Assert.Equal("Redox", vm.LessonInfo.Topic);
|
||||
@@ -41,13 +53,37 @@ public sealed class TeachingModeViewModelTests
|
||||
var sessions = new FakeSessions([]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
var created = Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(lesson.Id, created.LessonId);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession?.Id, created.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback: Schnellbewertungs-Dialoge (Mitarbeit, Anwesenheit/Hausaufgabe) sollen sich
|
||||
/// im Unterrichtsmodus direkt öffnen lassen — das setzt voraus, dass die dafür verwendete
|
||||
/// ParticipationTabViewModel-Instanz von Anfang an auf der zu dieser Stunde gehörenden
|
||||
/// Sitzung steht, nicht auf einer beliebigen anderen Sitzung der Gruppe.
|
||||
[Fact]
|
||||
public void Konstruktor_SelektiertDieselbeSitzungAuchInDerParticipationViewModel()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var otherSession = new ParticipationSession
|
||||
{ GroupId = group.Id, Date = lesson.Date.AddDays(-1), Comment = "Andere Stunde" };
|
||||
var sessions = new FakeSessions([otherSession]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.NotNull(vm.SeatingPlan.SelectedSession);
|
||||
Assert.Equal(vm.SeatingPlan.SelectedSession!.Id, vm.Participation.SelectedSession?.Id);
|
||||
Assert.NotEqual(otherSession.Id, vm.Participation.SelectedSession?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_BereitsExistierendeVerknuepfteSitzung_WirdWiederverwendet()
|
||||
{
|
||||
@@ -59,7 +95,8 @@ public sealed class TeachingModeViewModelTests
|
||||
var sessions = new FakeSessions([existing]);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group, sessions));
|
||||
|
||||
Assert.Single(sessions.GetByGroup(group.Id));
|
||||
Assert.Equal(existing.Id, vm.SeatingPlan.SelectedSession?.Id);
|
||||
@@ -75,8 +112,92 @@ public sealed class TeachingModeViewModelTests
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]), seatingPlan);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
new FakeLessons(), seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.SeatingPlan.IsEditable);
|
||||
}
|
||||
|
||||
// ── Hausaufgabe: letzte Stunde ansehen/abhaken, aktuelle einsehen/ändern ─────
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_FindetHausaufgabeDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren",
|
||||
Homework = "S. 42, Aufgabe 3", HomeworkChecked = false,
|
||||
};
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.True(vm.Homework.HasPreviousHomework);
|
||||
Assert.Equal("S. 42, Aufgabe 3", vm.Homework.PreviousHomeworkText);
|
||||
Assert.Contains("Säuren", vm.Homework.PreviousLessonLabel);
|
||||
Assert.False(vm.Homework.PreviousHomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Konstruktor_IgnoriertVorherigeStundeOhneHausaufgabe()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson { GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
Assert.False(vm.Homework.HasPreviousHomework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviousHomeworkChecked_SpeichertSofortAnDerVorherigenStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var previous = new Lesson
|
||||
{ GroupId = group.Id, Date = today.AddDays(-7), Topic = "Säuren", Homework = "S. 42" };
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(previous);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.PreviousHomeworkChecked = true;
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, today.AddDays(-120), today.AddDays(-1)));
|
||||
Assert.True(saved.HomeworkChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveCurrentHomework_SchreibtAufDieAktuelleStunde()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var lesson = new Lesson
|
||||
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var seatingPlan = BuildSeatingPlan(group.Id);
|
||||
var vm = new TeachingModeViewModel(lesson, group, new FakeAlternativeLessonPaths([]),
|
||||
lessons, seatingPlan, BuildParticipation(group));
|
||||
|
||||
vm.Homework.CurrentHomework = "AB 7 fertigstellen";
|
||||
vm.Homework.SaveCurrentHomeworkCommand.Execute(null);
|
||||
|
||||
var saved = Assert.Single(lessons.GetByGroupAndRange(group.Id, lesson.Date, lesson.Date));
|
||||
Assert.Equal("AB 7 fertigstellen", saved.Homework);
|
||||
Assert.Equal("Gespeichert.", vm.Homework.SaveStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class TeachingTimelineViewModelTests
|
||||
{
|
||||
private readonly DateTime _start = new(2026, 9, 14, 8, 0, 0, DateTimeKind.Utc);
|
||||
private DateTime _now;
|
||||
private readonly FakeLessons _lessons = new();
|
||||
|
||||
private (Lesson Lesson, TeachingTimelineViewModel Vm) Build(bool scheduled = true)
|
||||
{
|
||||
_now = _start.AddMinutes(5);
|
||||
var local = _start.ToLocalTime();
|
||||
var lesson = new Lesson
|
||||
{
|
||||
Date = DateOnly.FromDateTime(local),
|
||||
StartTime = scheduled ? TimeOnly.FromDateTime(local) : null,
|
||||
Phases = [new() { Name = "Einstieg", DurationMinutes = 10 },
|
||||
new() { Name = "Arbeit", DurationMinutes = 10 },
|
||||
new() { Name = "Sicherung", DurationMinutes = 10 }]
|
||||
};
|
||||
_lessons.Add(lesson);
|
||||
return (lesson, new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now));
|
||||
}
|
||||
|
||||
[Fact] public void Clock_HighlightsExactlyOnePhaseAtBoundary()
|
||||
{
|
||||
var (_, vm) = Build();
|
||||
Assert.True(vm.Phases[0].IsActive);
|
||||
Assert.Equal(50, vm.Phases[0].Progress);
|
||||
_now = _start.AddMinutes(10);
|
||||
vm.Refresh();
|
||||
Assert.True(vm.Phases[0].IsCompleted);
|
||||
Assert.True(vm.Phases[1].IsActive);
|
||||
Assert.Single(vm.Phases, p => p.IsActive);
|
||||
}
|
||||
|
||||
[Fact] public void NoStartTime_RequiresExplicitStart()
|
||||
{
|
||||
var (_, vm) = Build(scheduled: false);
|
||||
Assert.True(vm.NeedsStart);
|
||||
Assert.DoesNotContain(vm.Phases, p => p.IsActive);
|
||||
vm.StartNowCommand.Execute(null);
|
||||
Assert.False(vm.NeedsStart);
|
||||
Assert.True(vm.Phases[0].IsActive);
|
||||
Assert.Equal(_now, vm.Phases[0].StartUtc);
|
||||
}
|
||||
|
||||
[Fact] public void Extend_ShiftsLaterPhasesAndPreservesOriginalPlan()
|
||||
{
|
||||
var (lesson, vm) = Build();
|
||||
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||
Assert.Equal(_start.AddMinutes(20), vm.Phases[1].StartUtc);
|
||||
Assert.True(vm.Phases[2].IsOverflow);
|
||||
Assert.Equal(10, lesson.Phases[0].DurationMinutes);
|
||||
Assert.NotNull(_lessons.GetById(lesson.Id)!.TeachingTimeline);
|
||||
}
|
||||
|
||||
[Fact] public void FinishEarly_StartsNextPhaseNow()
|
||||
{
|
||||
var (_, vm) = Build();
|
||||
vm.Phases[0].FinishCommand.Execute(null);
|
||||
Assert.True(vm.Phases[0].IsCompleted);
|
||||
Assert.True(vm.Phases[1].IsActive);
|
||||
Assert.Equal(_now, vm.Phases[1].StartUtc);
|
||||
}
|
||||
|
||||
[Fact] public void Hold_SurvivesReopenAndContinuesOnlyOnNext()
|
||||
{
|
||||
var (lesson, vm) = Build();
|
||||
vm.Phases[0].HoldCommand.Execute(null);
|
||||
_now = _start.AddMinutes(65);
|
||||
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||
Assert.True(reopened.Phases[0].IsHeld);
|
||||
Assert.True(reopened.Phases[0].IsActive);
|
||||
Assert.Single(reopened.Phases, p => p.IsActive);
|
||||
reopened.Phases[0].FinishCommand.Execute(null);
|
||||
Assert.True(reopened.Phases[1].IsActive);
|
||||
Assert.False(reopened.Phases[0].IsHeld);
|
||||
Assert.Equal(_now, reopened.Phases[1].StartUtc);
|
||||
}
|
||||
|
||||
[Fact] public void BringForward_KeepsSkippedPendingPhasesBelowChosenPhase()
|
||||
{
|
||||
var (_, vm) = Build();
|
||||
var chosen = vm.Phases[2];
|
||||
chosen.BringForwardCommand.Execute(null);
|
||||
Assert.Equal(new[] { "Einstieg", "Sicherung", "Arbeit" }, vm.Phases.Select(p => p.Source.Name));
|
||||
Assert.True(chosen.IsActive);
|
||||
Assert.Equal(_now, chosen.StartUtc);
|
||||
Assert.False(vm.Phases[2].IsCompleted);
|
||||
}
|
||||
|
||||
[Fact] public void Overflow_RemainsPendingTheNextDayAndCanBeStartedNow()
|
||||
{
|
||||
var (lesson, vm) = Build();
|
||||
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||
_now = _start.AddDays(1);
|
||||
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||
var remainder = reopened.Phases[2];
|
||||
Assert.True(remainder.IsOverflow);
|
||||
Assert.False(remainder.IsCompleted);
|
||||
Assert.False(remainder.IsActive);
|
||||
Assert.True(reopened.HasRemainder);
|
||||
remainder.BringForwardCommand.Execute(null);
|
||||
Assert.True(remainder.IsActive);
|
||||
Assert.Equal(_now, remainder.StartUtc);
|
||||
}
|
||||
|
||||
[Fact] public void Transfer_CopiesIntoSelectedLessonOnlyOnceAndKeepsSource()
|
||||
{
|
||||
var (lesson, _) = Build();
|
||||
var target = new Lesson { GroupId = lesson.GroupId, Date = lesson.Date.AddDays(1), Topic = "Folgestunde" };
|
||||
_lessons.Add(target);
|
||||
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||
vm.Phases[0].ExtendTenCommand.Execute(null);
|
||||
vm.TransferRemainderCommand.Execute(null);
|
||||
vm.TransferRemainderCommand.Execute(null);
|
||||
var copy = Assert.Single(target.Phases);
|
||||
Assert.Equal("Sicherung", copy.Name);
|
||||
Assert.NotEqual(lesson.Phases[2].Id, copy.Id);
|
||||
Assert.Equal(3, lesson.Phases.Count);
|
||||
Assert.True(vm.Phases[2].IsTransferred);
|
||||
}
|
||||
|
||||
[Fact] public void PartiallyOverflowingPhase_PreservesOnlyUnfinishedMinutesAfterLessonEnds()
|
||||
{
|
||||
var (lesson, vm) = Build();
|
||||
vm.Phases[0].ExtendFiveCommand.Execute(null);
|
||||
_now = _start.AddMinutes(28);
|
||||
vm.Refresh();
|
||||
Assert.True(vm.Phases[2].IsActive);
|
||||
_now = _start.AddDays(1);
|
||||
var reopened = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||
var remainder = reopened.Phases[2];
|
||||
Assert.False(remainder.IsCompleted);
|
||||
Assert.False(remainder.IsActive);
|
||||
Assert.True(reopened.HasRemainder);
|
||||
Assert.Equal(5, remainder.RemainingMinutes);
|
||||
remainder.BringForwardCommand.Execute(null);
|
||||
Assert.True(remainder.IsActive);
|
||||
Assert.Equal(_now.AddMinutes(5), remainder.EndUtc);
|
||||
}
|
||||
|
||||
[Fact] public void ReadOnly_DoesNotChangeTimingOrStartClock()
|
||||
{
|
||||
var (lesson, _) = Build(scheduled: false);
|
||||
var vm = new TeachingTimelineViewModel(lesson, _lessons, readOnly: true, utcNow: () => _now);
|
||||
vm.StartNowCommand.Execute(null);
|
||||
Assert.Null(lesson.TeachingTimeline);
|
||||
Assert.True(vm.NeedsStart);
|
||||
}
|
||||
|
||||
[Fact] public void AlternativePhases_AreNotRunAlongsideMainPath()
|
||||
{
|
||||
var (lesson, _) = Build();
|
||||
lesson.Phases.Add(new LessonPhaseStep { AlternativePathId = Guid.NewGuid(), DurationMinutes = 30 });
|
||||
var vm = new TeachingTimelineViewModel(lesson, _lessons, utcNow: () => _now);
|
||||
Assert.Equal(3, vm.Phases.Count);
|
||||
Assert.Equal(_start.AddMinutes(30), vm.Phases[^1].EndUtc);
|
||||
}
|
||||
|
||||
[Fact] public void HeldTiming_RoundTripsThroughLiteDbWithoutTimezoneShift()
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var db = new LehrerApp.Data.LiteDbContext(stream);
|
||||
var repository = new LehrerApp.Data.Repositories.LessonRepository(db);
|
||||
var (lesson, _) = Build();
|
||||
repository.Save(lesson);
|
||||
var vm = new TeachingTimelineViewModel(lesson, repository, utcNow: () => _now);
|
||||
vm.Phases[0].HoldCommand.Execute(null);
|
||||
_now = _start.AddMinutes(40);
|
||||
var loaded = repository.GetById(lesson.Id)!;
|
||||
var reopened = new TeachingTimelineViewModel(loaded, repository, utcNow: () => _now);
|
||||
Assert.True(reopened.Phases[0].IsActive);
|
||||
Assert.True(reopened.Phases[0].IsHeld);
|
||||
Assert.Equal(_start, reopened.Phases[0].StartUtc);
|
||||
Assert.Equal(_start.AddMinutes(45), reopened.Phases[0].EndUtc);
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,11 @@ public sealed class TimetableViewModelTests
|
||||
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
|
||||
SchoolCalendarSettingsService? calendarSettings = null,
|
||||
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null,
|
||||
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null)
|
||||
FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null,
|
||||
PeriodScheduleService? periodSchedule = null)
|
||||
{
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf
|
||||
// (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
// Bewusst kein "using": SchoolCalendarSettingsService/PeriodScheduleService lesen den Pfad
|
||||
// nur bei Bedarf, das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-timetablevm-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
@@ -28,7 +29,8 @@ public sealed class TimetableViewModelTests
|
||||
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
|
||||
new PublicHolidayService(), new SchoolYearService(),
|
||||
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
|
||||
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService());
|
||||
untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService(),
|
||||
periodSchedule ?? new PeriodScheduleService(tempPath));
|
||||
}
|
||||
|
||||
/// Nächstes Datum ab (inkl.) <paramref name="from"/>, das auf einen Wochentag Mo-Fr fällt —
|
||||
@@ -275,7 +277,23 @@ public sealed class TimetableViewModelTests
|
||||
|
||||
Assert.Equal(group.Id, navigatedTo);
|
||||
Assert.False(viewerOpened);
|
||||
Assert.Equal("Zur Lerngruppe", vm.TodayItems[0].OpenButtonLabel);
|
||||
Assert.Equal("Stunde anlegen", vm.TodayItems[0].OpenButtonLabel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenTodayLesson_OhneLesson_StartetDirektanlageMitExaktemTermin()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]));
|
||||
TimetableLessonRequest? requested = null;
|
||||
vm.OnCreateLesson = request => { requested = request; return Task.CompletedTask; };
|
||||
|
||||
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
|
||||
|
||||
Assert.Equal(new TimetableLessonRequest(group.Id, today, 4), requested);
|
||||
}
|
||||
|
||||
// ── Unterrichtsmodus (14.x) ────────────────────────────────────────────────
|
||||
@@ -336,6 +354,88 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal(lesson.Id, openedLesson?.Id);
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): der Direktklick auf eine Wochenraster-Kachel soll
|
||||
/// "einheitlicher" springen — bei einer Lesson HEUTE, während gerade Unterrichtszeit ist,
|
||||
/// direkt in den Unterrichtsmodus statt in den (schreibgeschützten) Planungsviewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
// Bewusst nicht der reale DateTime.Today-Wochentag: an einem Sa/So gäbe es dafür keine
|
||||
// Kachel im Mo-Fr-Raster. Stattdessen ein beliebiger Wochentag der laufenden Kalenderwoche
|
||||
// (Kachel existiert immer), dessen Datum per VM.Clock als "heute" simuliert wird (siehe
|
||||
// TimetableViewModel.Clock - genau für solche Tests eingeführt).
|
||||
var weekday = DayOfWeek.Monday;
|
||||
var lessonDate = DateInCurrentWeek(weekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = weekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = lessonDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
// Gleiche Uhrzeit wie BuildPeriodSchedule (reales DateTime.Now), aber am simulierten
|
||||
// "heutigen" Datum - die Stundenzeit-Prüfung (Uhrzeit) bleibt so unabhängig vom Datum.
|
||||
vm.Clock = () => lessonDate.ToDateTime(TimeOnly.FromDateTime(DateTime.Now));
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == weekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, teachingModeLesson?.Id);
|
||||
Assert.Null(viewerLesson);
|
||||
}
|
||||
|
||||
/// Dieselbe Stundenzeit passt, die Lesson liegt aber nicht heute — bleibt beim Viewer.
|
||||
[Fact]
|
||||
public async Task OpenWeekCell_LessonNichtHeuteTrotzPassenderUhrzeit_OeffnetViewer()
|
||||
{
|
||||
var group = new LearningGroup { Name = "Q1 Chemie" };
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
var otherWeekday = today.DayOfWeek == DayOfWeek.Monday ? DayOfWeek.Tuesday : DayOfWeek.Monday;
|
||||
var otherDate = DateInCurrentWeek(otherWeekday);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = otherWeekday, PeriodNumber = 1 });
|
||||
var lesson = new Lesson { GroupId = group.Id, Date = otherDate, LessonNumber = 1, Topic = "Redox" };
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
var periodSchedule = BuildPeriodSchedule(1);
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons, periodSchedule: periodSchedule);
|
||||
Lesson? teachingModeLesson = null;
|
||||
Lesson? viewerLesson = null;
|
||||
vm.OnOpenTeachingMode = l => { teachingModeLesson = l; return Task.CompletedTask; };
|
||||
vm.OnOpenLessonViewer = l => { viewerLesson = l; return Task.CompletedTask; };
|
||||
|
||||
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == otherWeekday && c.PeriodNumber == 1);
|
||||
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
|
||||
|
||||
Assert.Equal(lesson.Id, viewerLesson?.Id);
|
||||
Assert.Null(teachingModeLesson);
|
||||
}
|
||||
|
||||
/// Weites Zeitfenster um <paramref name="around"/> herum (±2 Stunden), damit der Test nicht an
|
||||
/// die exakte Toleranz von TimetableViewModel.TeachingTimeTolerance gebunden ist.
|
||||
/// Start UND Ende exakt auf "jetzt" statt eines künstlich breiteren Fensters (z.B. ganzer Tag
|
||||
/// via TimeOnly.MinValue/MaxValue): TimetableViewModel.IsAroundTeachingTime addiert selbst
|
||||
/// noch ±10 Minuten Toleranz (TeachingTimeTolerance) auf Start/Ende — ein zusätzliches, vom
|
||||
/// Test vorgegebenes Fenster würde nahe Mitternacht durch die TimeOnly-Arithmetik (wrappt bei
|
||||
/// 24 Uhr) selbst kippen. "Jetzt" als Start/Ende bleibt nur dann riskant, wenn der Test
|
||||
/// zufällig innerhalb der letzten/ersten 10 Minuten des Tages läuft — dasselbe inhärente
|
||||
/// Randproblem hätte dann auch die Produktion, kein Testartefakt.
|
||||
private static PeriodScheduleService BuildPeriodSchedule(int periodNumber)
|
||||
{
|
||||
var tempPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempPath);
|
||||
var service = new PeriodScheduleService(tempPath);
|
||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||
service.SetPeriods([new PeriodTimeEntry { PeriodNumber = periodNumber, Start = now, End = now }]);
|
||||
return service;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSettings_RuftOnNavigateToSettingsAuf()
|
||||
{
|
||||
@@ -612,6 +712,31 @@ public sealed class TimetableViewModelTests
|
||||
Assert.Equal("1", period2.BadgeText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Doppelstunde_ZeigtLessonThemaInBeidenKacheln_AuchOhnePhasenUndStundenraster()
|
||||
{
|
||||
var date = DateInCurrentWeek(DayOfWeek.Monday);
|
||||
var group = new LearningGroup { Name = "10c" };
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 3 });
|
||||
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = date.DayOfWeek, PeriodNumber = 4 });
|
||||
var lesson = new Lesson
|
||||
{
|
||||
GroupId = group.Id, Date = date, LessonNumber = 3, Topic = "Werkstoffprüfung",
|
||||
Phases = [],
|
||||
};
|
||||
var lessons = new FakeLessons();
|
||||
lessons.Add(lesson);
|
||||
|
||||
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
|
||||
|
||||
var first = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 3);
|
||||
var second = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == date.DayOfWeek && c.PeriodNumber == 4);
|
||||
Assert.Equal("Werkstoffprüfung", first.Topic);
|
||||
Assert.Equal("Werkstoffprüfung", second.Topic);
|
||||
Assert.Equal(lesson.Id, second.Lesson?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_KeineAnstehendenFerien_KeinBadge()
|
||||
{
|
||||
@@ -1177,3 +1302,43 @@ public sealed class TimetableViewModelTests
|
||||
Assert.False(vm.HasUntisMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nutzer-Feedback (zweite Runde): das Popup-Menü je Wochenraster-Kachel (TimetableView.axaml,
|
||||
/// MenuFlyout) blendet "Unterrichtsansicht"/"Planungsviewer" per IsVisible="{Binding HasLesson}"
|
||||
/// aus, wenn für den Slot noch keine Lesson existiert, und den ganzen Menü-Trigger per
|
||||
/// IsVisible="{Binding HasGroupId}", wenn die Kachel gar keiner Gruppe zugeordnet ist (z.B. eine
|
||||
/// GroupId-lose Vertretung). Diese beiden Properties sind die Grundlage dafür.
|
||||
public sealed class WeekCellItemMenuVisibilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForSlot_MitLesson_HatHasLessonUndHasGroupId()
|
||||
{
|
||||
var lesson = new Lesson { Topic = "Redox" };
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "Redox", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false, lesson: lesson);
|
||||
|
||||
Assert.True(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSlot_OhneLesson_HatKeinHasLesson()
|
||||
{
|
||||
var cell = WeekCellItem.ForSlot(DayOfWeek.Monday, 3, isToday: true, "Che", "Q1 Chemie",
|
||||
"R204", "", "#4C8DFF", "", hasExam: false, isLastBeforeExam: false,
|
||||
hasExperiment: false, Guid.NewGuid(), isHoliday: false);
|
||||
|
||||
Assert.False(cell.HasLesson);
|
||||
Assert.True(cell.HasGroupId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSubstitutionLesson_OhneGroupId_HatKeinHasGroupId()
|
||||
{
|
||||
var cell = WeekCellItem.ForSubstitutionLesson(DayOfWeek.Monday, 3, isToday: true,
|
||||
new SubstitutionEntry { GroupId = null, GroupLabel = "Fremde Klasse", Description = "Vertretung" });
|
||||
|
||||
Assert.False(cell.HasGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,28 @@ public sealed class UnitDialogViewModelTests
|
||||
|
||||
Assert.Equal("10c · kein Fach hinterlegt (siehe Lerngruppe)", vm.GroupSubjectDisplay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Laufend_SetztBisherigeLaufendeEinheitAufAbgeschlossenUndWarntVorher()
|
||||
{
|
||||
var groupId = Guid.NewGuid();
|
||||
var units = new FakeUnits();
|
||||
var previous = new LehrerApp.Core.Models.Unit
|
||||
{
|
||||
GroupId = groupId, Title = "Bruchrechnung", Status = LehrerApp.Core.Models.UnitStatus.Active,
|
||||
};
|
||||
units.Add(previous);
|
||||
var vm = new UnitDialogViewModel(units, new FakeCompetencyDomains(),
|
||||
groupId, null, 6, "6a", "Mathematik", editingUnit: null)
|
||||
{
|
||||
Title = "Prozentrechnung",
|
||||
StatusName = "Laufend",
|
||||
};
|
||||
|
||||
Assert.Contains("Bruchrechnung", vm.StatusNotice);
|
||||
vm.SaveCommand.Execute(null);
|
||||
|
||||
Assert.Equal(LehrerApp.Core.Models.UnitStatus.Completed, previous.Status);
|
||||
Assert.Single(units.GetByGroup(groupId), u => u.Status == LehrerApp.Core.Models.UnitStatus.Active);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisHubServiceTests
|
||||
{
|
||||
private static readonly DateTime UtcNow = new(2026, 9, 10, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static LearningGroup Group(string name, int? webUntisLessonId, bool isActive = true) =>
|
||||
new() { Name = name, WebUntisLessonId = webUntisLessonId, IsActive = isActive };
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_ProGruppeMitLessonId_ZweiFehlzeitenzeilenPlusDreiGlobaleZeilen()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [], UtcNow);
|
||||
|
||||
Assert.Equal(5, rows.Count);
|
||||
Assert.Equal(2, rows.Count(r => r.GroupId == group.Id));
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenKurz && r.GroupId == group.Id);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.FehlzeitenLang && r.GroupId == group.Id);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.OffenePeriods && r.GroupId == null);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Klassenbuchabgleich && r.GroupId == null);
|
||||
Assert.Contains(rows, r => r.Kind == UntisHubJobKind.Hausaufgabenabgleich && r.GroupId == null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_NieGeprueft_GiltAlsUeberfaellig()
|
||||
{
|
||||
var rows = UntisHubService.BuildRows([], [], UtcNow);
|
||||
|
||||
Assert.All(rows, r => Assert.Equal(UntisHubDueState.Overdue, r.DueState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorSechsTagen_GiltAlsOk()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-6),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Ok, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorSechzehnTagen_GiltAlsFaellig()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-16),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Due, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenKurzVorZweiundzwanzigTagen_GiltAlsUeberfaellig()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var state = new UntisHubJobState
|
||||
{
|
||||
Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = UtcNow.AddDays(-22),
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], [state], UtcNow);
|
||||
|
||||
var row = rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz);
|
||||
Assert.Equal(UntisHubDueState.Overdue, row.DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_FehlzeitenLangHatDeutlichLaengereKadenzAlsKurz()
|
||||
{
|
||||
var group = Group("9a", 42);
|
||||
var lastRunAt = UtcNow.AddDays(-40);
|
||||
var states = new List<UntisHubJobState>
|
||||
{
|
||||
new() { Kind = UntisHubJobKind.FehlzeitenKurz, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||
new() { Kind = UntisHubJobKind.FehlzeitenLang, GroupId = group.Id, LastRunAt = lastRunAt },
|
||||
};
|
||||
|
||||
var rows = UntisHubService.BuildRows([group], states, UtcNow);
|
||||
|
||||
Assert.Equal(UntisHubDueState.Overdue, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenKurz).DueState);
|
||||
Assert.Equal(UntisHubDueState.Ok, rows.Single(r => r.Kind == UntisHubJobKind.FehlzeitenLang).DueState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRows_GruppeOhneWebUntisLessonId_WirdVonAufrufseiteAusgeschlossen()
|
||||
{
|
||||
// GetRows() filtert vorab auf WebUntisLessonId != null (siehe UntisHubService.GetRows) -
|
||||
// BuildRows selbst bekommt bereits nur die eligible-Gruppen übergeben.
|
||||
var eligible = new List<LearningGroup> { Group("9a", 42) };
|
||||
|
||||
var rows = UntisHubService.BuildRows(eligible, [], UtcNow);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisNameMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Schmidt Ben")]
|
||||
[InlineData("Ada Müller", "MÜLLER ADA")]
|
||||
[InlineData("Max Mustermann", "mustermann max")]
|
||||
public void NamesMatch_IstReihenfolgeUndGrossKleinschreibungUnabhaengig(string a, string b) =>
|
||||
Assert.True(UntisNameMatching.NamesMatch(a, b));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ben Schmidt", "Ben Müller")]
|
||||
[InlineData(null, "Ben Schmidt")]
|
||||
[InlineData("Ben Schmidt", "")]
|
||||
public void NamesMatch_LehntUnterschiedlicheOderFehlendeNamenAb(string? a, string b) =>
|
||||
Assert.False(UntisNameMatching.NamesMatch(a, b));
|
||||
|
||||
// Regression: Student.FullName liefert "Nachname, Vorname" (mit Komma) für Anzeigezwecke.
|
||||
// Wird dieser String direkt an NamesMatch übergeben, bleibt das Komma am Wort kleben
|
||||
// ("gerste," != "gerste") und der Abgleich gegen WebUntis-Namen (immer ohne Komma) schlägt
|
||||
// fehl - genau das ließ den Anwesenheitskalender/die Fehlzeitenliste im Elternbrief leer
|
||||
// bleiben, obwohl echte Fehlzeiten vorlagen. Aufrufer müssen deshalb "Vorname Nachname" ohne
|
||||
// Komma bilden (siehe StudentAttendanceCalendarDrawingBuilder.StudentAttendanceCalendarService
|
||||
// und ClassTeacherOverviewViewModel.cs:1173), statt Student.FullName direkt zu verwenden.
|
||||
[Fact]
|
||||
public void NamesMatch_KommaGetrennterAnzeigename_PasstNichtOhneUmformung()
|
||||
{
|
||||
Assert.False(UntisNameMatching.NamesMatch("Gerste, Amelia", "Gerste Amelia"));
|
||||
Assert.True(UntisNameMatching.NamesMatch("Amelia Gerste", "Gerste Amelia"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class UntisReportCacheServiceTests
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 8, 25);
|
||||
private static readonly DateOnly HotStart = Today.AddDays(-14);
|
||||
|
||||
// ── Plan(): reine Entscheidungslogik, keine Repositories/HTTP nötig ──────────
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErsterAufruf_LaedtHeissesFensterUndDeckungReichtBisFensteranfang()
|
||||
{
|
||||
var plan = UntisReportCacheService.Plan(null, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
Assert.Equal(HotStart, plan.HotWindowStart);
|
||||
Assert.Equal(Today, plan.HotWindowEnd);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
Assert.Equal(Int(HotStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_InnerhalbEinerStunde_KeinErneutesHeissesFenster()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ForceRefresh_LoestTrotzKuerzlichemAbrufAus()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddMinutes(-10), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: true, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_MehrAlsEineStundeVergangen_FrischtHeissesFensterErneutAuf()
|
||||
{
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow.AddHours(-2), ColdCoverageStartDate = Int(HotStart),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, Today, Today, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.True(plan.RefreshHotWindow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_AnfrageVorDemHeissenFenster_KeinHeissesFensterAberKalterErstabruf()
|
||||
{
|
||||
var requestStart = HotStart.AddDays(-30);
|
||||
var requestEnd = HotStart.AddDays(-5);
|
||||
|
||||
var plan = UntisReportCacheService.Plan(null, requestStart, requestEnd, Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(requestStart, plan.ColdRangeStart);
|
||||
Assert.Equal(HotStart.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(requestStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_BereitsKaltAbgedeckterBereich_KeinAbrufNoetig()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-60);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, coveredSince.AddDays(10), HotStart.AddDays(-20),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.False(plan.FetchColdRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_ErweitertKalteAbdeckungNurBisZumBenoetigtenStart()
|
||||
{
|
||||
var coveredSince = HotStart.AddDays(-30);
|
||||
var earlierStart = HotStart.AddDays(-90);
|
||||
var state = new UntisCacheFetchState
|
||||
{
|
||||
ClassName = "10c", Kind = UntisCacheKind.Absences,
|
||||
HotWindowFetchedAt = DateTime.UtcNow, ColdCoverageStartDate = Int(coveredSince),
|
||||
};
|
||||
|
||||
var plan = UntisReportCacheService.Plan(state, earlierStart, HotStart.AddDays(-40),
|
||||
Today, forceRefresh: false, DateTime.UtcNow);
|
||||
|
||||
Assert.False(plan.RefreshHotWindow);
|
||||
Assert.True(plan.FetchColdRange);
|
||||
Assert.Equal(earlierStart, plan.ColdRangeStart);
|
||||
Assert.Equal(coveredSince.AddDays(-1), plan.ColdRangeEnd);
|
||||
Assert.Equal(Int(earlierStart), plan.ResultingColdCoverageStartDate);
|
||||
}
|
||||
|
||||
private static int Int(DateOnly date) => date.Year * 10000 + date.Month * 100 + date.Day;
|
||||
|
||||
// ── Ende-zu-Ende: erster Aufruf ruft WebUntis ab, zweiter Aufruf binnen einer Stunde nicht ──
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassRegisterEventsAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var todayLabel = DateTime.Today.ToString("dd.MM.yy");
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Klasse\tDatum\tFach\tName\tBenutzer\tEintragskategorie\tKategoriegruppe\tText\r\n" +
|
||||
$"10c\t{todayLabel}\tNAT\tMuster Max\tmueller\tMitarb. über Erwart.\tPositiv\tArbeitet gut mit.\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=ClassregEventPerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
|
||||
var absenceCache = new FakeUntisAbsenceCache();
|
||||
var classRegisterCache = new FakeUntisClassRegisterCache();
|
||||
var rosterCache = new FakeUntisStudentRosterCache();
|
||||
var fetchStates = new FakeUntisCacheFetchStates();
|
||||
var cache = new UntisReportCacheService(untis, absenceCache, classRegisterCache, rosterCache, fetchStates);
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
var first = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(first);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetClassRegisterEventsAsync("10c", today, today);
|
||||
Assert.Single(second);
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStudentRosterAsync_ZweiterAufrufBinnenEinerStunde_RuftWebUntisNichtErneutAb()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"id\texternKey\tklasse.name\tname\tlongName\tforeName\r\n" +
|
||||
"1\t9001\t10c\tMUST\tMustermann\tMax\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"s\"}}"),
|
||||
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=Student&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) });
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var untis = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
await untis.ConnectAsync(new WebUntisCredentials("bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
|
||||
var cache = new UntisReportCacheService(untis, new FakeUntisAbsenceCache(), new FakeUntisClassRegisterCache(),
|
||||
new FakeUntisStudentRosterCache(), new FakeUntisCacheFetchStates());
|
||||
|
||||
var first = await cache.GetStudentRosterAsync("10c");
|
||||
Assert.Single(first);
|
||||
Assert.Equal("Max Mustermann", first[0].DisplayName);
|
||||
Assert.Equal(4, handler.Requests.Count);
|
||||
|
||||
var second = await cache.GetStudentRosterAsync("10c");
|
||||
Assert.Single(second);
|
||||
Assert.Equal(4, handler.Requests.Count); // keine weiteren Anfragen - aus dem Cache bedient
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
|
||||
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> _responses = new(responses);
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request.RequestUri?.ToString() ?? "");
|
||||
await Task.Yield();
|
||||
return _responses.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ public sealed class UntisSyncServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
|
||||
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndUeberspringtUnveraenderteWiederholung()
|
||||
{
|
||||
var mappings = new FakeUntisSlotMappings();
|
||||
mappings.Add(new UntisSlotMapping
|
||||
@@ -81,7 +81,7 @@ public sealed class UntisSyncServiceTests
|
||||
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
|
||||
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
|
||||
|
||||
Assert.Equal(1, second.SubstitutionCount);
|
||||
Assert.Equal(0, second.SubstitutionCount);
|
||||
Assert.Single(substitutions.GetAll());
|
||||
}
|
||||
|
||||
@@ -278,7 +278,7 @@ public sealed class UntisSyncServiceTests
|
||||
// Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen
|
||||
// zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid).
|
||||
var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
|
||||
Assert.Equal(1, second.SubstitutionCount);
|
||||
Assert.Equal(0, second.SubstitutionCount);
|
||||
Assert.Single(substitutions.GetAll());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,41 @@ public sealed class WebUntisIntegrationServiceTests
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"authenticate\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetClassAbsencesAsync_LoestKlasseNamensbasiertAufUndAdressiertSieAlsKLPlusId()
|
||||
{
|
||||
var csv = Encoding.UTF8.GetBytes(
|
||||
"Schüler*innen\tExterne Id\tText\tKlasse\tDatum\tWochentag\tFehlstd.\tFehlmin.\tLehrkraft\tFach\t" +
|
||||
"Abwesenheitsgrund\tText\tENr\tErledigt\tAbwesenheit zählt\tEntschuldigungstext\tStundennr.\tStatus\tFehltage\r\n" +
|
||||
"Muster Max\t12345\t\t10c\t24.08.26\tMo.\t1\t45\tHED\tChe\tAbsent\tKrank gemeldet\t555\t24.08.26\ttrue\t\t3\tnicht entsch.\t1\r\n");
|
||||
var handler = new QueueHandler(
|
||||
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"), // authenticate (ConnectAsync)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ConnectAsync-Validierung)
|
||||
Json("{\"result\":[{\"id\":7,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"), // getSchoolyears (ResolveClassIdAsync)
|
||||
Json("{\"result\":[{\"id\":874,\"name\":\"10c\"}]}"), // getKlassen
|
||||
Json("{\"data\":{\"finished\":true,\"error\":false," +
|
||||
"\"reportParams\":\"get=rpt.tmp&name=AbsencePerStudent&format=csv\"}}"),
|
||||
new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(csv) },
|
||||
Json("{\"result\":{}}")); // logout (Dispose)
|
||||
var settings = TestSupport.BuildWebUntisSettingsService();
|
||||
await using var service = new WebUntisIntegrationService(new HttpClient(handler), settings);
|
||||
|
||||
await service.ConnectAsync(new WebUntisCredentials(
|
||||
"bk-ostvest", "arche.webuntis.com", "lehrkraft", "geheim"));
|
||||
var entries = await service.GetClassAbsencesAsync("10c",
|
||||
new DateOnly(2026, 8, 24), new DateOnly(2026, 8, 28));
|
||||
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("Muster Max", entry.StudentName);
|
||||
Assert.Equal("nicht entsch.", entry.Status);
|
||||
// Zweimal getSchoolyears: einmal als ConnectAsync-Validierung, einmal in ResolveClassIdAsync.
|
||||
Assert.Equal(2, handler.Requests.Count(request => request.Body.Contains("\"method\":\"getSchoolyears\"")));
|
||||
Assert.Single(handler.Requests, request => request.Body.Contains("\"method\":\"getKlassen\""));
|
||||
Assert.Single(handler.Requests, request =>
|
||||
request.Uri.Contains("reports.do?name=AbsencePerStudent") &&
|
||||
request.Uri.Contains("klasseOrStudentgroupId=KL874"));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -572,6 +572,51 @@ public sealed class TimeTrackingViewModelTests
|
||||
|
||||
Assert.False(called);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingTeachingTime_VergangenerUnterrichtstag_WirdInZeiterfassungAngezeigt()
|
||||
{
|
||||
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
|
||||
var vm = new TimeTrackingViewModel(
|
||||
new FakeTimeEntries(), new FakeWorkTasks(), slots, periodSchedule);
|
||||
|
||||
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
Assert.Equal(new TimeOnly(7, 45), gap.WindowStart);
|
||||
Assert.Equal(new TimeOnly(8, 55), gap.WindowEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMissingTeachingTime_SpeichertVorgefuelltenTagUndEntferntIhnAusOffenerListe()
|
||||
{
|
||||
var pastDay = DateOnly.FromDateTime(DateTime.Today).AddDays(-1);
|
||||
var slots = new FakeTimetableSlots();
|
||||
slots.Add(new TimetableSlot { Weekday = pastDay.DayOfWeek, PeriodNumber = 1 });
|
||||
var periodSchedule = TestSupport.BuildPeriodScheduleService();
|
||||
periodSchedule.SetPeriods([new PeriodTimeEntry
|
||||
{ PeriodNumber = 1, Start = new TimeOnly(8, 0), End = new TimeOnly(8, 45) }]);
|
||||
var entries = new FakeTimeEntries();
|
||||
var vm = new TimeTrackingViewModel(entries, new FakeWorkTasks(), slots, periodSchedule);
|
||||
var gap = Assert.Single(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
vm.OnAddMissingTeachingTime = item => Task.FromResult<TimeEntry?>(new TimeEntry
|
||||
{
|
||||
Date = item.Date,
|
||||
Category = TaskCategoryDisplay.Label(TaskCategory.Teaching),
|
||||
StartTime = item.WindowStart,
|
||||
EndTime = item.WindowEnd,
|
||||
DurationMinutes = (int)(item.WindowEnd - item.WindowStart).TotalMinutes,
|
||||
});
|
||||
|
||||
await vm.AddMissingTeachingTimeCommand.ExecuteAsync(gap);
|
||||
|
||||
Assert.Single(entries.GetByDate(pastDay));
|
||||
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AddTimeEntryDialogViewModelTests
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using LehrerApp.Templating;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Desktop.Tests;
|
||||
|
||||
public sealed class _TempFlowCheck2
|
||||
{
|
||||
[Fact]
|
||||
public void MarkerTest()
|
||||
{
|
||||
var outDir = @"C:\Users\SHedt\AppData\Local\Temp\claude\d--source-LehrerApp\fb3681df-caaf-4f2d-9996-e343e44f5554\scratchpad";
|
||||
Directory.CreateDirectory(outDir);
|
||||
|
||||
var commands = new List<DrawingCommand>
|
||||
{
|
||||
new DrawStringEx(0, 0, 12, 170, "TOP-0", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||
new DrawStringEx(0, 90, 12, 170, "NEARBOTTOM-90", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||
new DrawStringEx(0, 105, 12, 170, "AFTERBOUNDARY-105", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||
new DrawStringEx(0, 190, 12, 170, "BOTTOM-190", DrawingTextAlignment.AlignLeft, 10, Color: "#000000"),
|
||||
};
|
||||
var drawing = new DrawingValue(commands, 200);
|
||||
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "marker", Name = "Marker",
|
||||
Placeholders = [new("Marker", PlaceholderType.Drawing, true)],
|
||||
};
|
||||
var layout = "PAGE 210 297 mm\nFLOWDRAWBOX 20 20 170 100 $Marker\n";
|
||||
var loadedLayout = new LayoutParser().Parse(layout);
|
||||
var loaded = new LoadedTemplate(manifest, loadedLayout, new Dictionary<string, byte[]>());
|
||||
var provider = new FakeProvider(new Dictionary<string, PlaceholderValue> { ["Marker"] = drawing });
|
||||
|
||||
var pngs = new QuestTemplateRenderer().RenderPagesToPng(loaded, provider, dpi: 150);
|
||||
for (var i = 0; i < pngs.Count; i++)
|
||||
File.WriteAllBytes(Path.Combine(outDir, $"marker2-{i}.png"), pngs[i]);
|
||||
}
|
||||
|
||||
private sealed class FakeProvider(IReadOnlyDictionary<string, PlaceholderValue> values) : ITemplateDataProvider
|
||||
{ public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values; }
|
||||
}
|
||||
@@ -2,6 +2,27 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="LehrerApp.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://LehrerApp.Desktop/Styles/SemanticBrushes.axaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Plattformunabhängige Vektoricons für die Hauptnavigation (14.10). -->
|
||||
<StreamGeometry x:Key="IconSearch">M10,2 A8,8 0 1 0 10,18 A8,8 0 1 0 10,2 M10,5 A5,5 0 1 1 10,15 A5,5 0 1 1 10,5 M15,14 L22,21 L20,23 L13,16 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconDashboard">M3,3 H10 V10 H3 Z M14,3 H21 V7 H14 Z M14,11 H21 V21 H14 Z M3,14 H10 V21 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconGroups">M3,8 L12,3 L21,8 V10 H3 Z M5,11 H8 V18 H5 Z M10,11 H14 V18 H10 Z M16,11 H19 V18 H16 Z M3,20 H21 V22 H3 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconStudents">M12,3 A4,4 0 1 0 12,11 A4,4 0 1 0 12,3 M4,21 C4,16 7,13 12,13 C17,13 20,16 20,21 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconExams">M5,2 H15 L20,7 V22 H5 Z M7,5 V19 H18 V9 H13 V4 H7 Z M9,11 H16 V13 H9 Z M9,15 H16 V17 H9 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconCalendar">M3,4 H21 V22 H3 Z M5,10 V20 H19 V10 Z M7,2 H9 V7 H7 Z M15,2 H17 V7 H15 Z M7,12 H10 V15 H7 Z M12,12 H15 V15 H12 Z M7,17 H10 V19 H7 Z M12,17 H15 V19 H12 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClock">M12,2 A10,10 0 1 0 12,22 A10,10 0 1 0 12,2 M12,5 A7,7 0 1 1 12,19 A7,7 0 1 1 12,5 M11,7 H13 V12 L17,14 L16,16 L11,13 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconClassTeacher">M2,8 L12,3 L22,8 L12,13 Z M6,11 V16 C9,19 15,19 18,16 V11 M22,8 V15</StreamGeometry>
|
||||
<!-- Klassenbuch mit Ausrufezeichen für noch offene Einträge. -->
|
||||
<StreamGeometry x:Key="IconOpenPeriods">M5,2 H19 A2,2 0 0 1 21,4 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V4 A2,2 0 0 1 5,2 Z M7,4 V20 H19 V4 Z M11,7 H14 V13 H11 Z M11,15 H14 V18 H11 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconSettings">M3,5 H21 V7 H3 Z M8,3 A3,3 0 1 0 8,9 A3,3 0 1 0 8,3 M3,11 H21 V13 H3 Z M16,9 A3,3 0 1 0 16,15 A3,3 0 1 0 16,9 M3,17 H21 V19 H3 Z M10,15 A3,3 0 1 0 10,21 A3,3 0 1 0 10,15</StreamGeometry>
|
||||
<StreamGeometry x:Key="IconRefresh">M12,3 A9,9 0 0 1 21,12 H18 A6,6 0 0 0 8,7 L11,10 H3 V2 L6,5 A9,9 0 0 1 12,3 M12,21 A9,9 0 0 1 3,12 H6 A6,6 0 0 0 16,17 L13,14 H21 V22 L18,19 A9,9 0 0 1 12,21</StreamGeometry>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
@@ -16,5 +37,17 @@
|
||||
<Setter Property="Opacity" Value="0.4"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style Selector="Button.touchTarget">
|
||||
<Setter Property="MinWidth" Value="40"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
</Style>
|
||||
<Style Selector="Button:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus-visible, ComboBox:focus-visible">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}"/>
|
||||
<Setter Property="BorderThickness" Value="2"/>
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
|
||||
+160
-13
@@ -1,8 +1,11 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Desktop.Services;
|
||||
@@ -10,7 +13,9 @@ using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.ViewModels.Workload;
|
||||
using LehrerApp.Desktop.Views;
|
||||
using LehrerApp.Desktop.Views.Workload;
|
||||
using LehrerApp.Sync;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -21,6 +26,8 @@ public class App : Application
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
private static ServiceProvider? _serviceProvider;
|
||||
private static bool _exitHandlerAttached;
|
||||
private static Task _initialPolls = Task.CompletedTask;
|
||||
private static readonly CancellationTokenSource StartupCancellation = new();
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
@@ -34,6 +41,21 @@ public class App : Application
|
||||
ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load());
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Erst den Splashscreen anzeigen und die synchrone Initialisierung in den ersten
|
||||
// Dispatcher-Durchlauf verschieben, damit das Ladebild vorher gezeichnet wird.
|
||||
var splash = new SplashWindow();
|
||||
desktop.MainWindow = splash;
|
||||
Dispatcher.UIThread.Post(
|
||||
() => ContinueStartup(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async void ContinueStartup(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
|
||||
{
|
||||
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
|
||||
// (und damit BuildServices, das sie öffnet) angefasst wird.
|
||||
@@ -42,34 +64,42 @@ public class App : Application
|
||||
var promptVm = new DbPasswordPromptViewModel(
|
||||
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
|
||||
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
|
||||
promptVm.OnUnlocked = password =>
|
||||
promptVm.OnUnlocked = async password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
StartMainApp(desktop, showImmediately: true);
|
||||
var unlockedSplash = new SplashWindow();
|
||||
desktop.MainWindow = unlockedSplash;
|
||||
unlockedSplash.Show();
|
||||
promptWindow.Close();
|
||||
await StartMainAppAsync(desktop, unlockedSplash);
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
}
|
||||
else
|
||||
{
|
||||
StartMainApp(desktop, showImmediately: false);
|
||||
}
|
||||
promptWindow.Show();
|
||||
splash.Close();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
await StartMainAppAsync(desktop, splash);
|
||||
}
|
||||
|
||||
private static void StartMainApp(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
|
||||
private static async Task StartMainAppAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||
{
|
||||
_serviceProvider = AppBootstrapper.BuildServices();
|
||||
var splash = windowToClose as SplashWindow;
|
||||
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||
var progress = new Progress<(int Value, string Text)>(step =>
|
||||
splash?.SetProgress(step.Value, step.Text));
|
||||
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
|
||||
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
|
||||
Services = _serviceProvider;
|
||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
||||
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||
// soll, kein dauerhaftes Archiv.
|
||||
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
|
||||
splash?.SetProgress(60, "Papierkorb aufräumen …");
|
||||
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
|
||||
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
|
||||
|
||||
if (!_exitHandlerAttached)
|
||||
{
|
||||
@@ -77,14 +107,49 @@ public class App : Application
|
||||
_exitHandlerAttached = true;
|
||||
}
|
||||
|
||||
// MCP-Server (lokal, Phase 1): Start ist ohne Wirkung, falls in den Einstellungen nicht
|
||||
// aktiviert (siehe McpServerHostedService.Start). Kein Live-Reload beim Umschalten des
|
||||
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||
|
||||
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
|
||||
var main = new MainWindow { DataContext = mainVm };
|
||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||
if (Services.GetService<SyncEngine>() is { } syncEngine)
|
||||
main.EnableFinalSync(syncEngine);
|
||||
desktop.MainWindow = main;
|
||||
if (showImmediately) main.Show();
|
||||
main.Show();
|
||||
splash?.SetProgress(100, "Bereit");
|
||||
windowToClose?.Close();
|
||||
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
|
||||
|
||||
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
|
||||
var untis = Services.GetService<UntisSyncService>();
|
||||
var annualPlan = Services.GetService<AnnualPlanSyncService>();
|
||||
if (untis is not null)
|
||||
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Services.GetRequiredService<TimetableViewModel>().Load();
|
||||
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
|
||||
});
|
||||
_initialPolls = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(
|
||||
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
|
||||
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
|
||||
}
|
||||
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
|
||||
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||
@@ -105,6 +170,8 @@ public class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
StartupCancellation.Cancel();
|
||||
_initialPolls.GetAwaiter().GetResult();
|
||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -142,6 +209,28 @@ public class App : Application
|
||||
dash.OnNavigateToLesson = id => main.NavigateToGroupDetail(id, 3); // Tab "Mitarbeit"
|
||||
dash.OnNavigateToExam = id => main.NavigateToGroupDetail(id, 4); // Tab "Klausuren"
|
||||
dash.OnNavigateToUnplannedLesson = id => main.NavigateToGroupDetail(id, 6); // Tab "Planung"
|
||||
dash.OnAddMissingTeachingTime = item => ShowMissingTeachingTimeDialog(item, dash);
|
||||
|
||||
var examsOverview = Services.GetRequiredService<ViewModels.Exams.ExamsOverviewViewModel>();
|
||||
examsOverview.OnNavigateToGroups = () => main.NavigateToCommand.Execute(NavItem.Groups);
|
||||
|
||||
// Globale Suche/Schnellerfassung (14.2): Navigation bleibt im MainWindow-VM, die
|
||||
// vorhandenen Dialog-Helfer übernehmen Eingabe und Validierung.
|
||||
var search = Services.GetRequiredService<GlobalSearchViewModel>();
|
||||
search.OnNavigate = result =>
|
||||
{
|
||||
if (result.Kind == GlobalSearchResultKind.Student && result.EntityId is { } studentId)
|
||||
main.NavigateToStudent(studentId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Group && result.GroupId is { } groupId)
|
||||
main.NavigateToGroupDetail(groupId);
|
||||
else if (result.Kind == GlobalSearchResultKind.Exam && result.GroupId is { } examGroupId)
|
||||
main.NavigateToGroupDetail(examGroupId, 4);
|
||||
else if (result.Kind == GlobalSearchResultKind.Task)
|
||||
main.NavigateToWorkload();
|
||||
};
|
||||
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
|
||||
search.OnQuickAddStudent = ShowAddStudentDialog;
|
||||
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
|
||||
|
||||
// StudentList → StudentDetail + Anlegen
|
||||
var sl = Services.GetRequiredService<StudentListViewModel>();
|
||||
@@ -162,4 +251,62 @@ public class App : Application
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
}
|
||||
|
||||
private static async Task ShowQuickTaskDialog(bool startAsReminder, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var result = await WorkTaskDialogHelper.ShowDialog(owner, startAsReminder: startAsReminder);
|
||||
if (result is null) return;
|
||||
|
||||
Services.GetRequiredService<IWorkTaskRepository>().Save(result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<WorkTaskListViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowMissingTeachingTimeDialog(MissingTeachingTimeItem item, DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var tasks = Services.GetRequiredService<IWorkTaskRepository>()
|
||||
.GetAll().Where(t => t.Status != WorkTaskStatus.Done).ToList();
|
||||
var vm = new AddTimeEntryDialogViewModel(tasks)
|
||||
{
|
||||
DateText = item.Date.ToString("dd.MM.yyyy"),
|
||||
SelectedCategory = "Unterricht",
|
||||
StartTimeText = item.WindowStart.ToString("HH:mm"),
|
||||
EndTimeText = item.WindowEnd.ToString("HH:mm"),
|
||||
};
|
||||
var dialog = new AddTimeEntryDialog { DataContext = vm };
|
||||
await dialog.ShowDialog<bool>(owner);
|
||||
if (vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<ITimeEntryRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
Services.GetRequiredService<TimeTrackingViewModel>().Load();
|
||||
}
|
||||
|
||||
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
|
||||
{ MainWindow: { } owner }) return;
|
||||
|
||||
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
|
||||
.ToDictionary(s => s.Id);
|
||||
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
|
||||
.Where(g => g.IsActive)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new GroupDocumentationOption(g.Id,
|
||||
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
|
||||
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
|
||||
: g.Name));
|
||||
var vm = new GroupDocumentationQuickViewModel(options);
|
||||
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
|
||||
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
|
||||
|
||||
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
|
||||
dashboard.RefreshCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ using LehrerApp.Core.Services;
|
||||
using LehrerApp.Data;
|
||||
using LehrerApp.Data.Repositories;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using LehrerApp.Desktop.ViewModels;
|
||||
using LehrerApp.Desktop.ViewModels.ClassTeacher;
|
||||
using LehrerApp.Desktop.ViewModels.Exams;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using LehrerApp.Desktop.ViewModels.Planning;
|
||||
using LehrerApp.Desktop.ViewModels.Settings;
|
||||
@@ -14,6 +18,7 @@ using LehrerApp.Sync;
|
||||
using LehrerApp.Sync.Crypto;
|
||||
using LehrerApp.Sync.Models;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
|
||||
@@ -96,7 +101,7 @@ public static class AppBootstrapper
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
public static ServiceProvider BuildServices()
|
||||
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -115,13 +120,25 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<NotificationService>();
|
||||
services.AddSingleton<ExportService>();
|
||||
services.AddSingleton<PdfExportService>();
|
||||
services.AddSingleton<ITemplateLoader, TemplateLoader>();
|
||||
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
|
||||
services.AddSingleton(_ => new TemplateStore(appData));
|
||||
services.AddSingleton(_ => new WorksheetTemplateStore(new TemplateStore(appData, subfolder: "worksheet-template-packages")));
|
||||
|
||||
// ── Datensicherheit (13.3) ───────────────────────────────────────────
|
||||
// Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von
|
||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||
var backup = new BackupService(appData);
|
||||
backup.CreateBackup(DbPath);
|
||||
var backupSettings = new BackupSettingsService(appData);
|
||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||
reportProgress?.Invoke(10, "Sicherung erstellen …");
|
||||
var backupPath = backup.CreateBackup(DbPath);
|
||||
reportProgress?.Invoke(25, "Sicherung prüfen …");
|
||||
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||
Logger.Warn($"Automatisches Backup {backupPath} lässt sich nicht öffnen/lesen — möglicherweise beschädigt.");
|
||||
services.AddSingleton(backup);
|
||||
services.AddSingleton(backupSettings);
|
||||
services.AddSingleton(_ => new AppLockService(appData));
|
||||
services.AddSingleton<DatabaseEncryptionService>();
|
||||
services.AddSingleton(_ => new PrivacySettingsService(appData));
|
||||
@@ -150,6 +167,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUnitRepository, UnitRepository>();
|
||||
services.AddSingleton<ILessonRepository, LessonRepository>();
|
||||
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
|
||||
services.AddSingleton<IVorgangRepository, VorgangRepository>();
|
||||
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
|
||||
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
|
||||
services.AddSingleton<IParticipationSessionRepository, ParticipationSessionRepository>();
|
||||
@@ -168,6 +186,11 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
services.AddSingleton<IUntisAbsenceCacheRepository, UntisAbsenceCacheRepository>();
|
||||
services.AddSingleton<IUntisClassRegisterCacheRepository, UntisClassRegisterCacheRepository>();
|
||||
services.AddSingleton<IUntisCacheFetchStateRepository, UntisCacheFetchStateRepository>();
|
||||
services.AddSingleton<IUntisStudentRosterCacheRepository, UntisStudentRosterCacheRepository>();
|
||||
services.AddSingleton<IUntisHubJobStateRepository, UntisHubJobStateRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -179,16 +202,40 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new PeriodScheduleService(appData));
|
||||
services.AddSingleton(_ => new WorkloadSettingsService(appData));
|
||||
services.AddSingleton(_ => new DashboardSettingsService(appData));
|
||||
services.AddSingleton(_ => new PatternScoreSettingsService(appData));
|
||||
services.AddSingleton(_ => new WindowSettingsService(appData));
|
||||
services.AddSingleton(_ => new AppearanceSettingsService(appData));
|
||||
services.AddSingleton(_ => new LetterTemplateService(appData));
|
||||
services.AddSingleton<PlanningExchangeService>();
|
||||
|
||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||
services.AddSingleton(_ => new AiSettingsService(appData));
|
||||
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
|
||||
services.AddSingleton(_ => new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(AiBackendUrl),
|
||||
// Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client
|
||||
// bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt.
|
||||
Timeout = TimeSpan.FromSeconds(210),
|
||||
});
|
||||
services.AddSingleton<AiPlanningService>();
|
||||
|
||||
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
||||
services.AddSingleton(_ => new McpSettingsService(appData));
|
||||
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
|
||||
services.AddSingleton<StudentTools>();
|
||||
services.AddSingleton<ExamTools>();
|
||||
services.AddSingleton<GradeTools>();
|
||||
services.AddSingleton<ScheduleTools>();
|
||||
services.AddSingleton<TimeEntryTools>();
|
||||
services.AddSingleton<LessonPlanTools>();
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<LetterTemplateTools>();
|
||||
services.AddSingleton<StudentAttendanceCalendarService>();
|
||||
services.AddSingleton<CompetencyTools>();
|
||||
services.AddSingleton<GroupTools>();
|
||||
services.AddSingleton<UntisComparisonTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
services.AddSingleton<McpClientRegistrationService>();
|
||||
|
||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||
var untisSettings = new WebUntisSettingsService(appData);
|
||||
services.AddSingleton(untisSettings);
|
||||
@@ -224,6 +271,8 @@ public static class AppBootstrapper
|
||||
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
|
||||
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
|
||||
services.AddSingleton(sp => new WebUntisIntegrationService(new HttpClient(), untisSettings));
|
||||
services.AddSingleton<UntisReportCacheService>();
|
||||
services.AddSingleton<UntisHubService>();
|
||||
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
|
||||
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
|
||||
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
|
||||
@@ -285,6 +334,7 @@ public static class AppBootstrapper
|
||||
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||
services.AddSingleton<AppLockViewModel>();
|
||||
services.AddSingleton<MainWindowViewModel>();
|
||||
services.AddSingleton<GlobalSearchViewModel>();
|
||||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddSingleton(sp =>
|
||||
new SyncStatusViewModel(
|
||||
@@ -297,6 +347,10 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<TimeTrackingViewModel>();
|
||||
services.AddSingleton<WorkloadEvaluationViewModel>();
|
||||
services.AddSingleton<WorkloadViewModel>();
|
||||
services.AddSingleton<ClassTeacherDetailsViewModel>();
|
||||
services.AddSingleton<ClassTeacherCasesViewModel>();
|
||||
services.AddSingleton<ClassTeacherOverviewViewModel>();
|
||||
services.AddSingleton<ExamsOverviewViewModel>();
|
||||
|
||||
// Transient: neue Instanz pro Navigation (für Detailseiten)
|
||||
services.AddTransient<GroupDetailViewModel>();
|
||||
@@ -312,7 +366,9 @@ public static class AppBootstrapper
|
||||
services.AddTransient<TrashViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
|
||||
var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<LiteDbContext>();
|
||||
|
||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>`CalendarDatePicker.SelectedDate` ist (anders als der eingebaute `DatePicker`) vom
|
||||
/// WPF-Toolkit übernommenes `DateTime?`, während Datumsfelder in dieser Codebasis durchgängig
|
||||
/// `DateTimeOffset`(?) sind. Avalonia bietet dafür keine automatische Konvertierung
|
||||
/// (`TypeUtilities.TryConvert`/`TryConvertImplicit` unterstützen `DateTime`↔`DateTimeOffset`
|
||||
/// nicht) — ohne diesen Converter bleibt die Bindung beim Anzeigen stumm leer und wirft beim
|
||||
/// Zurückschreiben eine `InvalidCastException`.</summary>
|
||||
public sealed class DateTimeOffsetToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public static readonly DateTimeOffsetToDateTimeConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTimeOffset offset ? offset.DateTime : null;
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is DateTime dateTime ? new DateTimeOffset(DateTime.SpecifyKind(dateTime, DateTimeKind.Local)) : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
|
||||
namespace LehrerApp.Desktop.Converters;
|
||||
|
||||
/// <summary>Multipliziert die gerenderte Breite eines Referenzelements (typischerweise die neutrale
|
||||
/// Track-Leiste eines gestapelten Balkens) mit einem Anteil 0…1. Ersetzt eine im ViewModel hart
|
||||
/// codierte Pixelkonstante (Quick-Win, Nutzer-Feedback) — die Segmentbreite bleibt damit auch
|
||||
/// korrekt, wenn sich die Breite der Seitenspalte in XAML mal ändert, weil sie sich aus der
|
||||
/// tatsächlichen <c>Bounds.Width</c> des Track-Elements ergibt statt aus einer angenommenen
|
||||
/// festen Breite. `Grid.ColumnDefinitions` ließ sich dafür nicht binden (Avalonia bietet dort bei
|
||||
/// kompilierten Bindings keinen Setter — <c>AVLN3000</c>), deshalb MultiBinding auf Pixelbreite
|
||||
/// statt Grid-Sternspalten.</summary>
|
||||
public sealed class FractionWidthConverter : IMultiValueConverter
|
||||
{
|
||||
public static readonly FractionWidthConverter Instance = new();
|
||||
|
||||
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (values is not [double trackWidth, double fraction, ..] || double.IsNaN(trackWidth)) return 0d;
|
||||
return Math.Max(0d, trackWidth * fraction);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.WebUntis\LehrerApp.WebUntis.csproj" />
|
||||
<ProjectReference Include="..\LehrerApp.Templating\LehrerApp.Templating.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
@@ -20,6 +21,7 @@
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="QuestPDF" />
|
||||
<PackageReference Include="ModelContextProtocol.Core" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia;
|
||||
using LehrerApp.Core.Mcp;
|
||||
using LehrerApp.Desktop.Services;
|
||||
|
||||
namespace LehrerApp.Desktop;
|
||||
@@ -8,6 +9,10 @@ class Program
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Muss vor der ersten NamedPipeServerStream-Instanz laufen (siehe
|
||||
// McpServerHostedService), sonst löst TMPDIR-Auseinanderdriften zwischen diesem Prozess
|
||||
// und LehrerApp.McpBridge auf macOS/Linux "MCP nicht erreichbar" aus - siehe Doku dort.
|
||||
McpPipeConstants.EnsureStableUnixSocketDirectory();
|
||||
var logger = AppBootstrapper.EnsureLogger();
|
||||
GlobalExceptionHandler.Install(logger);
|
||||
|
||||
|
||||
@@ -10,7 +10,14 @@ using System.Text.Json.Serialization;
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
|
||||
public class AiBackendException(string userMessage) : Exception(userMessage);
|
||||
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
|
||||
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
|
||||
/// </summary>
|
||||
public string? RawResponse { get; } = rawResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
||||
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||
{
|
||||
string? backendReason = null;
|
||||
string? rawResponse = null;
|
||||
try
|
||||
{
|
||||
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
|
||||
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
|
||||
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
|
||||
}
|
||||
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||
|
||||
return new AiBackendException(backendReason is null
|
||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
|
||||
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
|
||||
}
|
||||
|
||||
public async Task<string> LoginAsync(string username, string password)
|
||||
@@ -178,6 +188,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
StartTime = l.StartTime,
|
||||
Homework = l.Homework,
|
||||
Reflection = l.Reflection,
|
||||
PlanningIdeas = l.PlanningIdeas,
|
||||
Phases = ToAiPhases(l.Phases, pathNames),
|
||||
})
|
||||
.ToList();
|
||||
@@ -260,6 +271,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal))
|
||||
diffs.Add("Reflexion geändert");
|
||||
|
||||
if (!string.Equals(existing.PlanningIdeas, proposed.PlanningIdeas, StringComparison.Ordinal))
|
||||
diffs.Add("Planungsideen geändert");
|
||||
|
||||
var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name);
|
||||
var existingPhases = ToAiPhases(existing.Phases, pathNames);
|
||||
if (!PhasesEqual(existingPhases, proposed.Phases))
|
||||
@@ -340,6 +354,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
throw new AiBackendException(
|
||||
"Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " +
|
||||
"Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
@@ -352,14 +372,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
var responseText = await resp.Content.ReadAsStringAsync();
|
||||
try
|
||||
{
|
||||
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
|
||||
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
|
||||
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
string? rawModelResponse = null;
|
||||
try
|
||||
{
|
||||
using var envelope = JsonDocument.Parse(responseText);
|
||||
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
|
||||
&& rawElement.ValueKind == JsonValueKind.String)
|
||||
rawModelResponse = rawElement.GetString();
|
||||
}
|
||||
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
|
||||
|
||||
throw new AiBackendException(
|
||||
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
|
||||
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
|
||||
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
|
||||
/// der Rohantwort markiert werden kann.
|
||||
/// </summary>
|
||||
public static List<AiLesson> ParsePlanningLessons(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
List<AiLesson>? parsed = root.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
|
||||
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
|
||||
JsonValueKind.Object =>
|
||||
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
|
||||
?? throw new JsonException("Leere Stunde")],
|
||||
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (parsed is null || parsed.Count == 0)
|
||||
throw new JsonException("Keine Stunde enthalten");
|
||||
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
|
||||
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
|
||||
return parsed;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +590,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
StartTime = ai.StartTime,
|
||||
Homework = ai.Homework,
|
||||
Reflection = ai.Reflection,
|
||||
PlanningIdeas = ai.PlanningIdeas,
|
||||
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
|
||||
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
|
||||
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
|
||||
@@ -531,6 +602,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
Activity = p.Activity,
|
||||
Material = p.Material,
|
||||
Shorthand = p.Shorthand,
|
||||
// Persistiert den Prompt (4.5.20/4.5.36), damit er nach dem Übernehmen weiterhin
|
||||
// im Stundeneditor kopierbar bleibt statt nur einmalig im Review-Dialog.
|
||||
MaterialPrompt = string.IsNullOrWhiteSpace(p.MaterialSuggestion)
|
||||
? null : BuildMaterialPrompt(unit, ai, p),
|
||||
AlternativePathId = p.AlternativePathName is { } name && pathIdsByName.TryGetValue(name, out var pathId)
|
||||
? pathId : null,
|
||||
}).ToList(),
|
||||
@@ -539,7 +614,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fragt für eine Charge anonymisierter Fehlzeitenzeilen (ai-backend/untis-status.php,
|
||||
/// Nutzer-Feedback zum Untis-Hub) einen Statusvorschlag ab - eine Anfrage für alle fraglichen
|
||||
/// Zeilen eines Abgleichslaufs statt einer je Zeile (Kosten/Latenz-Überlegung aus der
|
||||
/// Nutzerdiskussion). Liefert nur dann Vorschläge zurück, wenn die vom Backend gemeldete
|
||||
/// Id-Menge exakt der gesendeten entspricht (keine fehlenden, zusätzlichen oder doppelten Ids) -
|
||||
/// andernfalls eine leere Zuordnung, statt sich auf eine möglicherweise vermischte Reihenfolge
|
||||
/// zu verlassen. Der Aufrufer behält für jede nicht zurückgelieferte Id den bisherigen
|
||||
/// regelbasierten Status bei.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, string>> RequestUntisStatusSuggestionsAsync(
|
||||
IReadOnlyList<AiUntisStatusRow> rows, string token)
|
||||
{
|
||||
if (rows.Count == 0) return new Dictionary<string, string>();
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, "untis-status.php")
|
||||
{
|
||||
Content = JsonContent.Create(new AiUntisStatusRequest { Rows = rows.ToList() }, options: JsonOptions),
|
||||
};
|
||||
req.Headers.Authorization = new("Bearer", token);
|
||||
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await http.SendAsync(req); }
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||
}
|
||||
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
|
||||
if (resp.StatusCode == (HttpStatusCode)402)
|
||||
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw await BuildRequestFailedExceptionAsync(resp);
|
||||
|
||||
AiUntisStatusResponse? result;
|
||||
try { result = await resp.Content.ReadFromJsonAsync<AiUntisStatusResponse>(JsonOptions); }
|
||||
catch (Exception ex) when (ex is not AiBackendException)
|
||||
{
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
|
||||
}
|
||||
if (result is null)
|
||||
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||
|
||||
var sentIds = rows.Select(r => r.Id).ToHashSet();
|
||||
var receivedIds = result.Suggestions.Select(s => s.Id).ToList();
|
||||
if (receivedIds.Count != sentIds.Count || receivedIds.Distinct().Count() != receivedIds.Count
|
||||
|| !sentIds.SetEquals(receivedIds))
|
||||
return new Dictionary<string, string>();
|
||||
|
||||
return result.Suggestions.ToDictionary(s => s.Id, s => s.Status);
|
||||
}
|
||||
|
||||
private class LoginResult { public string Token { get; set; } = ""; }
|
||||
private class BalanceResult { public decimal BalanceUsd { get; set; } }
|
||||
private class BackendErrorResult { public string? Error { get; set; } }
|
||||
private class BackendErrorResult
|
||||
{
|
||||
public string? Error { get; set; }
|
||||
public string? RawResponse { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
public async Task PollAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0)) return;
|
||||
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||
try
|
||||
{
|
||||
var url = _settings.GetIcalUrl();
|
||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url);
|
||||
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -59,7 +59,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
AnnualPlanPollResult result;
|
||||
try
|
||||
{
|
||||
result = ProcessIcsText(icsText);
|
||||
result = await Task.Run(() => ProcessIcsText(icsText)).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
internal sealed class BackupSettingsConfig
|
||||
{
|
||||
public string? SecondaryDirectory { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Merkt sich einen optionalen zweiten Sicherungsordner (13.3.1 Nachtrag) — z.B. ein
|
||||
/// USB-Stick oder Netzlaufwerk, damit ein Backup nicht ausschließlich neben der Live-Datenbank
|
||||
/// liegt und bei Plattenausfall oder gelöschtem Profilverzeichnis mit verloren geht. Gleiches
|
||||
/// Muster wie <see cref="AppearanceSettingsService"/>. Das eigentliche Kopieren übernimmt
|
||||
/// <see cref="LehrerApp.Core.Services.BackupService"/> bewusst best-effort.</summary>
|
||||
public sealed class BackupSettingsService
|
||||
{
|
||||
private readonly string _configPath;
|
||||
|
||||
public BackupSettingsService(string appDataPath) =>
|
||||
_configPath = Path.Combine(appDataPath, "backupsettings.json");
|
||||
|
||||
public string? LoadSecondaryDirectory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_configPath))
|
||||
return JsonSerializer.Deserialize<BackupSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?.SecondaryDirectory;
|
||||
}
|
||||
catch { /* beschädigte Konfiguration -> kein zweites Ziel */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
public void SaveSecondaryDirectory(string? path) =>
|
||||
File.WriteAllText(_configPath, JsonSerializer.Serialize(new BackupSettingsConfig { SecondaryDirectory = path }));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Avalonia.Controls;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.ViewModels.Students;
|
||||
using LehrerApp.Desktop.Views.Students;
|
||||
using LehrerApp.Templating;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>Öffnet den bestehenden Elternbrief-Dialog für einen Schüler, egal ob der Aufruf aus
|
||||
/// der Schülerdetailansicht (Student schon im DataContext) oder aus dem Formulare-Menü (Student
|
||||
/// erst per Picker gewählt) kommt.</summary>
|
||||
public static class LetterDialogs
|
||||
{
|
||||
public static async Task ShowCreateLetterDialogAsync(Window owner, Student student)
|
||||
{
|
||||
var vm = new CreateLetterDialogViewModel(student,
|
||||
App.Services.GetRequiredService<TemplateStore>(),
|
||||
App.Services.GetRequiredService<ITemplateRenderer>(),
|
||||
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||
App.Services.GetRequiredService<IGroupRepository>(),
|
||||
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||
.Build(student, options, contentWidth, millimeterScale, contentHeight),
|
||||
(options, contentWidth, millimeterScale, contentHeight) => App.Services.GetRequiredService<StudentAttendanceCalendarService>()
|
||||
.BuildAbsenceDayList(student, options, contentWidth, millimeterScale),
|
||||
RefreshAttendanceDataAsync);
|
||||
var dialog = new CreateLetterDialog { DataContext = vm };
|
||||
var path = await dialog.ShowDialog<string?>(owner);
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
/// <summary>Holt genau den im Anwesenheitskalender-Dialog gewählten Zeitraum gezielt per
|
||||
/// WebUntis nach (ausgelöst durch den expliziten "Konfigurieren…"-Klick, kein Hintergrundabruf
|
||||
/// beim bloßen Öffnen des Briefdialogs). Damit sieht <see cref="StudentAttendanceCalendarService"/>
|
||||
/// anschließend frische Daten im lokalen Cache, statt stillschweigend "keine Fehltage" zu
|
||||
/// melden, nur weil die Klassenlehrer-Übersicht für diesen Zeitraum noch nie geöffnet wurde.</summary>
|
||||
private static async Task RefreshAttendanceDataAsync(AttendanceCalendarOptions options, CancellationToken token)
|
||||
{
|
||||
var className = App.Services.GetRequiredService<WebUntisSettingsService>().HomeroomClassName;
|
||||
if (string.IsNullOrWhiteSpace(className)) return;
|
||||
var cache = App.Services.GetRequiredService<UntisReportCacheService>();
|
||||
var start = options.NormalizedStartMonth;
|
||||
var end = start.AddMonths(options.NormalizedMonthCount).AddDays(-1);
|
||||
await cache.GetAbsencesAsync(className, start, end, token: token);
|
||||
await cache.GetClassRegisterEventsAsync(className, start, end, token: token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Templating;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Standard-Platzhalterwerte für Elternbrief-Vorlagen (Datum, Anrede, Kontaktadresse,
|
||||
/// Schülerdaten, Brieftext, ...). Ursprünglich Teil von
|
||||
/// <see cref="ViewModels.Students.CreateLetterDialogViewModel"/>, hierher extrahiert, damit
|
||||
/// <c>LetterTemplateTools</c> (MCP, siehe Planungsdokument) dieselbe Logik nutzt statt sie zu
|
||||
/// duplizieren — beide Aufrufer müssen exakt dieselben Platzhalternamen befüllen, sonst driften
|
||||
/// Dialog-generierte und KI-generierte Briefe unbemerkt auseinander.
|
||||
/// </summary>
|
||||
public static class LetterPlaceholderBuilder
|
||||
{
|
||||
public static Dictionary<string, PlaceholderValue> BuildStandardValues(
|
||||
Student student, Contact? contact, LearningGroup? group, DateOnly date,
|
||||
string letterText, string teacherName, DrawingValue? attendanceCalendar = null,
|
||||
DrawingValue? absenceDays = null)
|
||||
{
|
||||
var address = FormatAddress(contact);
|
||||
return new Dictionary<string, PlaceholderValue>(StringComparer.Ordinal)
|
||||
{
|
||||
["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date),
|
||||
["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Brieftext"] = new MultilineValue(letterText), ["LehrerName"] = new TextValue(teacherName),
|
||||
["Student.FirstName"] = new TextValue(student.FirstName), ["Student.LastName"] = new TextValue(student.LastName),
|
||||
["Student.Name"] = new TextValue(student.FullName),
|
||||
["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address),
|
||||
["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""),
|
||||
["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""),
|
||||
["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""),
|
||||
[StudentAttendanceCalendarDrawingBuilder.PlaceholderName] = attendanceCalendar ?? new DrawingValue([], 0),
|
||||
[StudentAbsenceDayListDrawingBuilder.PlaceholderName] = absenceDays ?? new DrawingValue([], 0),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Mehrzeilige Anschrift (Name/Straße/PLZ Ort) für Adressvorschau im Dialog und
|
||||
/// den <c>Contact.Address</c>-Platzhalter - eine Formatierung für beide Verwendungen.</summary>
|
||||
public static string FormatAddress(Contact? contact)
|
||||
{
|
||||
var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
}
|
||||
|
||||
public static bool IsEmpty(PlaceholderValue value) => value switch
|
||||
{
|
||||
TextValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
MultilineValue x => string.IsNullOrWhiteSpace(x.Value),
|
||||
DrawingValue x => x.ContentHeight <= 0 || x.Commands.Count == 0,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Tatsächliche DRAWBOX/FLOWDRAWBOX-Größe eines Platzhalters (in der Maßeinheit der
|
||||
/// Vorlage) plus dem Umrechnungsfaktor "1mm in dieser Maßeinheit". DrawingValue-Builder wie
|
||||
/// <see cref="StudentAttendanceCalendarDrawingBuilder"/> entwerfen ihr Raster in Millimetern;
|
||||
/// ohne MillimeterScale bliebe das bei einer in "pt" (statt "mm") deklarierten Vorlage viel zu
|
||||
/// klein (1 "mm-Einheit" würde als 1pt ≈ 0.35mm gerendert). IsFixed unterscheidet DRAWBOX (feste,
|
||||
/// nicht umbrechende Box - Inhalt, der die Höhe sprengt, wird von QuestTemplateRenderer
|
||||
/// stillschweigend am unteren Rand abgeschnitten statt umzubrechen) von FLOWDRAWBOX (fließt bei
|
||||
/// Bedarf automatisch auf weitere Seiten, siehe DrawingElementRenderer.Slice) - nur bei
|
||||
/// IsFixed=true darf/muss ein Builder seine Höhe an Height anpassen.</summary>
|
||||
public readonly record struct DeclaredDrawingBox(float Width, float Height, float MillimeterScale, bool IsFixed);
|
||||
|
||||
public static DeclaredDrawingBox? FindDeclaredDrawingBox(LoadedTemplate template, string placeholderName) =>
|
||||
FindDeclaredDrawingBox(template.Layout, placeholderName) ??
|
||||
(template.ContinuationLayout is not null ? FindDeclaredDrawingBox(template.ContinuationLayout, placeholderName) : null);
|
||||
|
||||
private static DeclaredDrawingBox? FindDeclaredDrawingBox(TemplateLayout layout, string placeholderName)
|
||||
{
|
||||
var box = layout.Elements.Concat(layout.PageTemplates.SelectMany(p => p.Elements))
|
||||
.Concat(layout.ContentFlows.SelectMany(f => f.Elements))
|
||||
.Select(e => e switch
|
||||
{
|
||||
DrawBoxElement draw when draw.Placeholder == placeholderName => ((float Width, float Height, bool IsFixed)?)(draw.Width, draw.Height, true),
|
||||
FlowDrawBoxElement flow when flow.Placeholder == placeholderName => (flow.Width, flow.Height, false),
|
||||
_ => null,
|
||||
})
|
||||
.FirstOrDefault(b => b is not null);
|
||||
if (box is null) return null;
|
||||
var millimeterScale = UnitConverter.Points(1, "mm") / UnitConverter.Points(1, layout.Unit);
|
||||
return new DeclaredDrawingBox(box.Value.Width, box.Value.Height, millimeterScale, box.Value.IsFixed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Views.Mcp;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt <see cref="McpConfirmDialog"/>
|
||||
/// über dem Hauptfenster an. Der aufrufende Tool-Handler läuft auf einem Hintergrund-Thread
|
||||
/// (MCP-Pipe-Session in <see cref="McpServerHostedService"/>), deshalb Marshalling über
|
||||
/// <see cref="Dispatcher.UIThread"/>.
|
||||
///
|
||||
/// Nutzer-Feedback: der Dialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief (naheliegend,
|
||||
/// da der Anstoß von einem KI-Client in einem anderen Fenster kommt) — deshalb wird das Hauptfenster
|
||||
/// vor dem Anzeigen aus einer möglichen Minimierung geholt und aktiviert, und der Dialog selbst
|
||||
/// läuft `Topmost`.
|
||||
///
|
||||
/// Nutzer-Feedback (Nachtrag): bei vielen gleichartigen Vorschlägen in Folge (z.B. 17x
|
||||
/// "add_lesson_phase" für eine neu generierte Unterrichtseinheit) einzeln nachfragen zu müssen, ist
|
||||
/// unzumutbar. Der Dialog bietet deshalb zwei Sitzungsfreigaben an ("diese Aktion" / "alle
|
||||
/// Aktionen"), die als reines In-Memory-Bookkeeping auf dieser Singleton-Instanz leben — sie gelten
|
||||
/// bis zum Beenden der App (neuer Prozess = neue Instanz = wieder alles ungetraut) und werden nie
|
||||
/// persistiert. <paramref name="operationKey"/> ist der Name der aufrufenden Tool-Methode (siehe
|
||||
/// <see cref="IMcpConfirmationService.ConfirmAsync"/>), nicht der MCP-Wire-Name.
|
||||
///
|
||||
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||
/// Änderung als abgelehnt gewertet.
|
||||
/// </summary>
|
||||
public sealed class AvaloniaMcpConfirmationService(AppLogger logger) : IMcpConfirmationService
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly object _trustLock = new();
|
||||
private readonly HashSet<string> _trustedOperations = [];
|
||||
private bool _trustAll;
|
||||
|
||||
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true)
|
||||
{
|
||||
if (allowSessionTrust)
|
||||
{
|
||||
bool alreadyTrusted;
|
||||
lock (_trustLock) alreadyTrusted = _trustAll || _trustedOperations.Contains(operationKey);
|
||||
if (alreadyTrusted)
|
||||
{
|
||||
logger.Info($"MCP: „{title}“ automatisch bestätigt (Sitzungsfreigabe für " +
|
||||
$"{(_trustAll ? "alle Aktionen" : operationKey)}).");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||
return false;
|
||||
|
||||
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
|
||||
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||
// sicher aufgerufen werden kann.
|
||||
var result = await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
if (owner.WindowState == WindowState.Minimized) owner.WindowState = WindowState.Normal;
|
||||
owner.Activate();
|
||||
|
||||
var dialog = new McpConfirmDialog
|
||||
{
|
||||
DataContext = new McpConfirmDialogInfo
|
||||
{
|
||||
Title = title, Message = message, ConfirmText = "Übernehmen",
|
||||
AllowSessionTrust = allowSessionTrust,
|
||||
},
|
||||
};
|
||||
var dialogTask = dialog.ShowDialog<McpConfirmDialogResult>(owner);
|
||||
var timeoutTask = Task.Delay(Timeout, ct);
|
||||
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||
if (completed != dialogTask)
|
||||
{
|
||||
dialog.Close(McpConfirmDialogResult.Rejected);
|
||||
return McpConfirmDialogResult.Rejected;
|
||||
}
|
||||
return await dialogTask;
|
||||
});
|
||||
|
||||
if (result.Approved && allowSessionTrust)
|
||||
{
|
||||
lock (_trustLock)
|
||||
{
|
||||
if (result.TrustAll) _trustAll = true;
|
||||
else if (result.TrustOperation) _trustedOperations.Add(operationKey);
|
||||
}
|
||||
}
|
||||
return result.Approved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
|
||||
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
|
||||
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
|
||||
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
|
||||
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
|
||||
/// </summary>
|
||||
public interface IMcpConfirmationService
|
||||
{
|
||||
/// <param name="operationKey">Identifiziert die Art der Operation für eine mögliche
|
||||
/// Sitzungsfreigabe ("diese Aktion für den Rest der Sitzung nicht mehr nachfragen") — bewusst
|
||||
/// per <see cref="CallerMemberNameAttribute"/> automatisch befüllt (der Name der aufrufenden
|
||||
/// Tool-Methode, z.B. "AddLessonPhase"), damit kein Aufrufer diesen Parameter selbst pflegen
|
||||
/// muss. Nicht Teil des MCP-Wire-Protokolls, rein internes Bestätigungs-Bookkeeping.</param>
|
||||
/// <param name="allowSessionTrust">False für die eine bewusste Ausnahme, bei der eine
|
||||
/// Sitzungsfreigabe nicht angeboten werden soll (Nutzer-Entscheidung zu
|
||||
/// <c>get_named_untis_absence_pattern</c>: eine namentliche Fehlzeitenauskunft muss JEDES MAL
|
||||
/// einzeln bestätigt werden, nie pauschal für die restliche Sitzung) — weder die vorherige
|
||||
/// Prüfung auf eine bereits bestehende Freigabe noch das Setzen einer neuen finden dann statt,
|
||||
/// unabhängig davon, ob zuvor schon "alle Aktionen" freigegeben wurde.</param>
|
||||
/// <returns>true, wenn der Nutzer bestätigt hat (direkt oder über eine bereits erteilte
|
||||
/// Sitzungsfreigabe); false bei Ablehnung, Timeout oder falls kein Hauptfenster verfügbar ist
|
||||
/// (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct,
|
||||
[CallerMemberName] string operationKey = "", bool allowSessionTrust = true);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
public record McpRegistrationResult(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Trägt LehrerApp.McpBridge in die MCP-Server-Konfiguration von Claude Desktop ein (siehe
|
||||
/// Planungsdokument, "Registrierungs-Workflow"). Bewusst nur für Claude Desktop: das ist der einzige
|
||||
/// KI-Client, den die Spec konkret benennt und der eine dokumentierte, stabile Config-Datei-Konvention
|
||||
/// hat (<c>mcpServers</c>-Objekt in <c>claude_desktop_config.json</c>) — andere lokale Clients
|
||||
/// (Ollama, LM Studio, ...) haben keine einheitliche Konvention, die sich hier ohne Rätselraten
|
||||
/// unterstützen ließe.
|
||||
///
|
||||
/// Läuft nur auf explizite Nutzeraktion (Button in den Einstellungen), nie automatisch beim
|
||||
/// App-Start — das Schreiben in die Konfigurationsdatei eines fremden Programms ist ein sichtbarer
|
||||
/// externer Seiteneffekt und gehört nicht in den normalen Startpfad.
|
||||
///
|
||||
/// Setzt eine gepackte Installation voraus (Bridge liegt neben der Hauptapp-Executable, siehe
|
||||
/// build-macos-app.sh) — bei einem lokalen "dotnet build/run" liegt die Bridge in ihrem eigenen
|
||||
/// separaten bin-Ordner, <see cref="ResolveBridgePath"/> findet sie dann nicht.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
private const string ServerName = "lehrerapp";
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
private readonly string? _bridgePath;
|
||||
|
||||
public McpClientRegistrationService() : this(ResolveConfigPath(), ResolveBridgePath()) { }
|
||||
|
||||
/// <summary>Test-Seam: erlaubt, Konfigurations- und Bridge-Pfad ohne echte
|
||||
/// Sonderordner/Installation vorzugeben (siehe <see cref="ResolveConfigPath"/>/
|
||||
/// <see cref="ResolveBridgePath"/> für das produktive Verhalten).</summary>
|
||||
public McpClientRegistrationService(string? configPath, string? bridgePath)
|
||||
{
|
||||
ConfigPath = configPath;
|
||||
_bridgePath = bridgePath;
|
||||
}
|
||||
|
||||
public string? ConfigPath { get; }
|
||||
|
||||
/// <summary>Ob überhaupt ein Claude-Desktop-Konfigurationsordner existiert — ein Hinweis, ob die
|
||||
/// Anwendung installiert ist, unabhängig davon, ob LehrerApp dort schon eingetragen ist.</summary>
|
||||
public bool IsClaudeDesktopDetected => ConfigPath is not null && File.Exists(ConfigPath);
|
||||
|
||||
public bool IsRegistered() =>
|
||||
TryLoadRoot(out var root, out _) && root["mcpServers"]?[ServerName] is not null;
|
||||
|
||||
public McpRegistrationResult Register()
|
||||
{
|
||||
if (ConfigPath is null)
|
||||
return new(false, "Claude Desktop wird auf diesem Betriebssystem nicht unterstützt.");
|
||||
|
||||
var bridgePath = _bridgePath;
|
||||
if (bridgePath is null || !File.Exists(bridgePath))
|
||||
return new(false,
|
||||
$"Bridge-Programm nicht gefunden ({bridgePath ?? "unbekannter Pfad"}). " +
|
||||
"Diese Funktion setzt eine gepackte Installation voraus, nicht einen lokalen Entwicklungs-Build.");
|
||||
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
|
||||
if (root["mcpServers"] is not JsonObject servers)
|
||||
{
|
||||
servers = new JsonObject();
|
||||
root["mcpServers"] = servers;
|
||||
}
|
||||
servers[ServerName] = new JsonObject
|
||||
{
|
||||
["command"] = bridgePath,
|
||||
["args"] = new JsonArray(),
|
||||
};
|
||||
|
||||
var detectedBefore = IsClaudeDesktopDetected;
|
||||
var result = WriteRoot(root, "In Claude Desktop eingetragen. Claude Desktop neu starten, damit die Änderung wirkt.");
|
||||
if (result.Success && !detectedBefore)
|
||||
return result with
|
||||
{
|
||||
Message = result.Message +
|
||||
" Hinweis: Es wurde keine bestehende Claude-Desktop-Installation erkannt — die Konfiguration " +
|
||||
"greift erst, sobald Claude Desktop installiert ist.",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
public McpRegistrationResult Unregister()
|
||||
{
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath))
|
||||
return new(true, "Nichts einzutragen gefunden.");
|
||||
if (!TryLoadRoot(out var root, out var error))
|
||||
return new(false, error!);
|
||||
if (root["mcpServers"] is not JsonObject servers || !servers.Remove(ServerName))
|
||||
return new(true, "War nicht eingetragen.");
|
||||
return WriteRoot(root, "Eintrag entfernt.");
|
||||
}
|
||||
|
||||
private bool TryLoadRoot(out JsonObject root, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (ConfigPath is null || !File.Exists(ConfigPath)) { root = new JsonObject(); return true; }
|
||||
try
|
||||
{
|
||||
root = JsonNode.Parse(File.ReadAllText(ConfigPath)) as JsonObject ?? new JsonObject();
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
// Nicht raten und überschreiben - eine kaputte fremde Konfigurationsdatei bleibt unangetastet,
|
||||
// der Nutzer bekommt stattdessen eine klare Fehlermeldung mit Pfad.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration ist kein gültiges JSON ({ex.Message}). " +
|
||||
$"Bitte manuell prüfen: {ConfigPath}";
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Z.B. gesperrt, weil Claude Desktop selbst gerade schreibt - ebenfalls nicht überschreiben.
|
||||
root = new JsonObject();
|
||||
error = $"Bestehende Claude-Desktop-Konfiguration konnte nicht gelesen werden: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private McpRegistrationResult WriteRoot(JsonObject root, string successMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(ConfigPath!)!);
|
||||
File.WriteAllText(ConfigPath!, root.ToJsonString(WriteOptions));
|
||||
return new(true, successMessage);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new(false, $"Konfiguration konnte nicht geschrieben werden: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveBridgePath()
|
||||
{
|
||||
var bridgeFile = OperatingSystem.IsWindows() ? "LehrerApp.McpBridge.exe" : "LehrerApp.McpBridge";
|
||||
return Path.Combine(AppContext.BaseDirectory, bridgeFile);
|
||||
}
|
||||
|
||||
private static string? ResolveConfigPath()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Claude", "claude_desktop_config.json");
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
"Library", "Application Support", "Claude", "claude_desktop_config.json");
|
||||
return null; // Linux: kein offizieller Claude-Desktop-Client bekannt.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.IO.Pipes;
|
||||
using LehrerApp.Core.Mcp;
|
||||
using LehrerApp.Core.Services;
|
||||
using LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// In-Process-MCP-Server (siehe Planungsdokument und TODO.md 4.5.25ff.). Lauscht auf der Named Pipe
|
||||
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||
/// als Ein-/Ausgabe der Session übergeben werden, ohne eigenes JSON-RPC-Parsing.
|
||||
///
|
||||
/// Nur aktiv, wenn <see cref="McpSettingsService.Enabled"/> — sonst tut <see cref="Start"/> nichts.
|
||||
/// Repositories sind im DI-Container Singletons (siehe AppBootstrapper), deshalb reicht es, die
|
||||
/// Tool-Instanzen und die daraus gebaute <see cref="McpServerOptions"/> einmalig zu bauen und für
|
||||
/// alle Sessions zu teilen.
|
||||
/// </summary>
|
||||
public sealed class McpServerHostedService : IAsyncDisposable
|
||||
{
|
||||
private readonly McpSettingsService _settings;
|
||||
private readonly AppLogger _logger;
|
||||
private readonly McpServerOptions _serverOptions;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _acceptLoop;
|
||||
|
||||
public McpServerHostedService(
|
||||
McpSettingsService settings, AppLogger logger,
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_serverOptions = BuildServerOptions(
|
||||
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||
groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools);
|
||||
}
|
||||
|
||||
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls
|
||||
/// bereits gestartet oder in den Einstellungen deaktiviert (dann bleibt keine Pipe offen).</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (!_settings.Enabled || _cts is not null) return;
|
||||
_cts = new CancellationTokenSource();
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
_logger.Info("MCP-Server gestartet, lauscht auf Pipe '" + McpPipeConstants.PipeName + "'.");
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var pipe = new NamedPipeServerStream(
|
||||
McpPipeConstants.PipeName, PipeDirection.InOut,
|
||||
NamedPipeServerStream.MaxAllowedServerInstances,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
try
|
||||
{
|
||||
await pipe.WaitForConnectionAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await pipe.DisposeAsync();
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("MCP: Fehler beim Warten auf eine Bridge-Verbindung.", ex);
|
||||
await pipe.DisposeAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nicht awaiten: die Accept-Loop muss sofort weiterlaufen, damit mehrere gleichzeitige
|
||||
// Bridge-Instanzen (mehrere KI-Client-Sitzungen) unabhängig bedient werden.
|
||||
_ = RunSessionAsync(pipe, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSessionAsync(NamedPipeServerStream pipe, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var transport = new StreamServerTransport(pipe, pipe, "LehrerApp");
|
||||
await using var server = McpServer.Create(transport, _serverOptions, loggerFactory: null, serviceProvider: null);
|
||||
await server.RunAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.Warn($"MCP: Session beendet ({ex.Message}).");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null) return;
|
||||
await _cts.CancelAsync();
|
||||
if (_acceptLoop is not null)
|
||||
{
|
||||
try { await _acceptLoop; }
|
||||
catch { /* Beenden über Cancellation ist der Normalfall hier */ }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private static McpServerOptions BuildServerOptions(
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
|
||||
CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools)
|
||||
{
|
||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||
|
||||
void AddReadTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
||||
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
||||
// Destructive bewusst false (keine dieser Schreiboperationen löscht etwas) - für die
|
||||
// Ausnahme siehe AddDestructiveWriteTool/McpToolScope.AllowedDestructiveWriteTools.
|
||||
void AddWriteTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = false,
|
||||
Destructive = false,
|
||||
}));
|
||||
}
|
||||
|
||||
void AddDestructiveWriteTool(Delegate handler, string name, string description)
|
||||
{
|
||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ReadOnly = false,
|
||||
Destructive = true,
|
||||
}));
|
||||
}
|
||||
|
||||
AddReadTool(studentTools.GetStudents, "get_students",
|
||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(groupTools.GetGroups, "get_groups",
|
||||
"Listet Lerngruppen (Klassen/Kurse) mit ihrer Id, optional gefiltert nach Schuljahr.");
|
||||
AddReadTool(examTools.GetExams, "get_exams",
|
||||
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(gradeTools.GetGrades, "get_grades",
|
||||
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
||||
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
||||
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
||||
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
||||
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
||||
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
|
||||
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
||||
AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment",
|
||||
"Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter.");
|
||||
AddReadTool(letterTemplateTools.ListLetterTemplates, "list_letter_templates",
|
||||
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
|
||||
AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
|
||||
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF.");
|
||||
AddReadTool(competencyTools.GetSubjects, "get_subjects",
|
||||
"Listet alle Fächer.");
|
||||
AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog",
|
||||
"Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe.");
|
||||
AddReadTool(untisComparisonTools.GetUntisHubStatus, "get_untis_hub_status",
|
||||
"Listet die Fälligkeit der Untis-Hub-Abgleiche, ohne selbst WebUntis anzufragen.");
|
||||
AddReadTool(untisComparisonTools.GetUntisAbsenceRows, "get_untis_absence_rows",
|
||||
"Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile).");
|
||||
AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern",
|
||||
"Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung.");
|
||||
|
||||
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
|
||||
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
|
||||
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.CreateUnit, "create_unit",
|
||||
"Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit",
|
||||
"Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson",
|
||||
"Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson",
|
||||
"Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase",
|
||||
"Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase",
|
||||
"Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase",
|
||||
"Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.AddLessonAttachment, "add_lesson_attachment",
|
||||
"Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson",
|
||||
"Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency",
|
||||
"Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency",
|
||||
"Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.CreateSubject, "create_subject",
|
||||
"Legt ein neues Fach an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.UpdateSubject, "update_subject",
|
||||
"Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain",
|
||||
"Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain",
|
||||
"Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item",
|
||||
"Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item",
|
||||
"Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item",
|
||||
"Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig).");
|
||||
AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status",
|
||||
"Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen).");
|
||||
|
||||
AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
|
||||
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||
AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject",
|
||||
"Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||
AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain",
|
||||
"Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (Bestätigung durch den Nutzer nötig).");
|
||||
|
||||
System.Diagnostics.Debug.Assert(
|
||||
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools)
|
||||
.Concat(McpToolScope.AllowedDestructiveWriteTools).OrderBy(n => n)),
|
||||
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||
|
||||
return new McpServerOptions
|
||||
{
|
||||
ServerInfo = new Implementation { Name = "LehrerApp", Version = "1.0.0" },
|
||||
Capabilities = new ServerCapabilities { Tools = new ToolsCapability() },
|
||||
ToolCollection = toolCollection,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace LehrerApp.Desktop.Services.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Allowlist der über MCP exponierten Tool-Namen. Dieselbe Absicherung wie
|
||||
/// LehrerApp.Api/PlainEventStore.cs (dort für den Klartext-Sync-Kanal): Gesprächsnotizen, Vorfälle
|
||||
/// und Förderpläne (Documentation/Vorgang) sind hier bewusst nie aufgeführt und werden von keiner
|
||||
/// Tool-Klasse referenziert — ein KI-Client kann diese Daten technisch nicht erreichen, unabhängig
|
||||
/// davon, wie vertrauenswürdig der lokale Modell-Client erscheint oder wie die Tool-Liste künftig
|
||||
/// wächst. <see cref="McpServerHostedService"/> registriert nur exakt diese Namen.
|
||||
///
|
||||
/// "get_named_untis_absence_pattern" ist die eine bewusste, eng begrenzte Ausnahme von der oben
|
||||
/// beschriebenen Regel (Nutzer-Entscheidung, siehe TODO.md): es verknüpft Schülername mit
|
||||
/// Fehlzeitendaten, aber nur für explizit angegebene Schüler-IDs und nur nach jedes Mal gesonderter,
|
||||
/// prominenter Bestätigung ohne Sitzungsfreigabe (siehe UntisComparisonTools). Documentation/
|
||||
/// Vorgang bleibt davon unberührt weiterhin vollständig ausgeschlossen.
|
||||
/// </summary>
|
||||
public static class McpToolScope
|
||||
{
|
||||
public static readonly IReadOnlyCollection<string> AllowedReadTools =
|
||||
[
|
||||
"get_students",
|
||||
"get_groups",
|
||||
"get_exams",
|
||||
"get_grades",
|
||||
"get_schedule",
|
||||
"get_time_entries",
|
||||
"get_lesson_plans",
|
||||
"download_lesson_attachment",
|
||||
"list_letter_templates",
|
||||
"render_letter",
|
||||
"get_subjects",
|
||||
"get_competency_catalog",
|
||||
"get_untis_hub_status",
|
||||
"get_untis_absence_rows",
|
||||
"get_named_untis_absence_pattern",
|
||||
];
|
||||
|
||||
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig
|
||||
/// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase)
|
||||
/// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe
|
||||
/// Begründung in LessonPlanTools.</summary>
|
||||
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
|
||||
[
|
||||
"create_time_entry",
|
||||
"create_grade_entry",
|
||||
"update_student_group_assignment",
|
||||
"create_unit",
|
||||
"update_unit",
|
||||
"create_lesson",
|
||||
"update_lesson",
|
||||
"add_lesson_phase",
|
||||
"update_lesson_phase",
|
||||
"remove_lesson_phase",
|
||||
"add_lesson_attachment",
|
||||
"move_lesson",
|
||||
"add_lesson_competency",
|
||||
"remove_lesson_competency",
|
||||
"create_subject",
|
||||
"update_subject",
|
||||
"create_competency_domain",
|
||||
"update_competency_domain",
|
||||
"add_competency_item",
|
||||
"update_competency_item",
|
||||
"remove_competency_item",
|
||||
"apply_untis_absence_status",
|
||||
];
|
||||
|
||||
/// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
|
||||
/// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
|
||||
/// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
|
||||
/// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
|
||||
/// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
|
||||
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
|
||||
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
|
||||
[
|
||||
"delete_lesson",
|
||||
"delete_subject",
|
||||
"delete_competency_domain",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
|
||||
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
|
||||
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
|
||||
/// <see cref="LessonPlanTools"/> (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie
|
||||
/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst.
|
||||
///
|
||||
/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools)
|
||||
/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der
|
||||
/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe
|
||||
/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht.</summary>
|
||||
public class CompetencyTools(
|
||||
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
|
||||
{
|
||||
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Description("Listet alle Fächer.")]
|
||||
public List<SubjectDto> GetSubjects() =>
|
||||
subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList();
|
||||
|
||||
[Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")]
|
||||
public List<CompetencyDomainDto> GetCompetencyCatalog(
|
||||
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||
[Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null)
|
||||
{
|
||||
var result = gradeLevel is not null
|
||||
? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value)
|
||||
: domains.GetBySubject(subjectId);
|
||||
return result.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
// ── Fächer (Subject) ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> CreateSubject(
|
||||
[Description("Name des Fachs, z.B. \"Mathematik\".")] string name,
|
||||
[Description("Kurzform, z.B. \"Ma\".")] string shortName = "",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein.");
|
||||
|
||||
var message = $"Neues Fach „{name.Trim()}“" +
|
||||
(string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?";
|
||||
if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var subject = new Subject { Name = name, ShortName = shortName };
|
||||
try { subjects.Save(subject); }
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return new WriteResultDto(false, null, ex.Message);
|
||||
}
|
||||
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||
}
|
||||
|
||||
[Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")]
|
||||
public async Task<WriteResultDto> UpdateSubject(
|
||||
[Description("Fach-ID.")] Guid subjectId,
|
||||
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||
[Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var subject = subjects.GetById(subjectId);
|
||||
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; }
|
||||
if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; }
|
||||
|
||||
if (changes.Length == 0)
|
||||
return new WriteResultDto(true, subject.Id, "Keine Änderung nötig.");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
try { subjects.Save(subject); }
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
return new WriteResultDto(false, null, ex.Message);
|
||||
}
|
||||
return new WriteResultDto(true, subject.Id, "Fach gespeichert.");
|
||||
}
|
||||
|
||||
[Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> DeleteSubject(
|
||||
[Description("Fach-ID.")] Guid subjectId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var subject = subjects.GetById(subjectId);
|
||||
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||
|
||||
var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer).";
|
||||
if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
try { subjects.Delete(subjectId); }
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new WriteResultDto(false, null, ex.Message);
|
||||
}
|
||||
return new WriteResultDto(true, subjectId, "Fach gelöscht.");
|
||||
}
|
||||
|
||||
// ── Kompetenzbereiche (CompetencyDomain) ────────────────────────────────────────────────
|
||||
|
||||
[Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> CreateCompetencyDomain(
|
||||
[Description("Fach-ID, aus get_subjects.")] Guid subjectId,
|
||||
[Description("Klassenstufe.")] int gradeLevel,
|
||||
[Description("Name des Bereichs.")] string name,
|
||||
[Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var subject = subjects.GetById(subjectId);
|
||||
if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID.");
|
||||
if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein.");
|
||||
|
||||
var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count;
|
||||
var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var domain = new CompetencyDomain
|
||||
{
|
||||
SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder,
|
||||
};
|
||||
domains.Save(domain);
|
||||
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||
}
|
||||
|
||||
[Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")]
|
||||
public async Task<WriteResultDto> UpdateCompetencyDomain(
|
||||
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null,
|
||||
[Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var domain = domains.GetById(domainId);
|
||||
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; }
|
||||
if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; }
|
||||
|
||||
if (changes.Length == 0)
|
||||
return new WriteResultDto(true, domain.Id, "Keine Änderung nötig.");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
domains.Save(domain);
|
||||
return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert.");
|
||||
}
|
||||
|
||||
[Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> DeleteCompetencyDomain(
|
||||
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var domain = domains.GetById(domainId);
|
||||
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||
|
||||
var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " +
|
||||
"Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche).";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
domains.Delete(domainId);
|
||||
return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht.");
|
||||
}
|
||||
|
||||
// ── Einzelkompetenzen (CompetencyItem) ──────────────────────────────────────────────────
|
||||
|
||||
[Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> AddCompetencyItem(
|
||||
[Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId,
|
||||
[Description("Kompetenzcode, z.B. \"M.5.1\".")] string code,
|
||||
[Description("Beschreibung der Kompetenz.")] string description,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var domain = domains.GetById(domainId);
|
||||
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||
if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein.");
|
||||
|
||||
var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count };
|
||||
domain.Items.Add(item);
|
||||
domains.Save(domain);
|
||||
return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt.");
|
||||
}
|
||||
|
||||
[Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")]
|
||||
public async Task<WriteResultDto> UpdateCompetencyItem(
|
||||
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||
[Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null,
|
||||
[Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var domain = domains.GetById(domainId);
|
||||
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; }
|
||||
if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; }
|
||||
|
||||
if (changes.Length == 0)
|
||||
return new WriteResultDto(true, item.Id, "Keine Änderung nötig.");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
domains.Save(domain);
|
||||
return new WriteResultDto(true, item.Id, "Kompetenz gespeichert.");
|
||||
}
|
||||
|
||||
[Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")]
|
||||
public async Task<WriteResultDto> RemoveCompetencyItem(
|
||||
[Description("ID des Kompetenzbereichs.")] Guid domainId,
|
||||
[Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var domain = domains.GetById(domainId);
|
||||
if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID.");
|
||||
var item = domain.Items.FirstOrDefault(i => i.Id == itemId);
|
||||
if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich.");
|
||||
|
||||
var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?";
|
||||
if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
domain.Items.Remove(item);
|
||||
domains.Save(domain);
|
||||
return new WriteResultDto(true, item.Id, "Kompetenz entfernt.");
|
||||
}
|
||||
|
||||
private static CompetencyDomainDto ToDto(CompetencyDomain d) => new(
|
||||
d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder,
|
||||
d.Items.OrderBy(i => i.SortOrder)
|
||||
.Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
// Schlanke, bewusst nicht 1:1 zu den LiteDB-Entities gehaltene Rückgabetypen: verhindert, dass ein
|
||||
// später zum Modell hinzugefügtes Feld (z.B. ein neues personenbezogenes Attribut) unbeabsichtigt
|
||||
// über ein MCP-Tool nach außen dringt, nur weil es Teil der Entity-Klasse ist.
|
||||
|
||||
public record StudentDto(Guid Id, string FirstName, string LastName, bool IsActive);
|
||||
|
||||
public record ExamResultDto(Guid StudentId, double TotalPoints, string? Grade, bool Absent);
|
||||
|
||||
public record ExamDto(
|
||||
Guid Id, Guid GroupId, string Title, DateOnly Date, ExamStatus Status, Niveau? Niveau,
|
||||
List<ExamResultDto>? Results);
|
||||
|
||||
public record GradeDto(
|
||||
Guid Id, Guid StudentId, Guid GroupId, GradeCategory Category, string Value, DateOnly Date,
|
||||
double Weight, string? Note);
|
||||
|
||||
public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int PeriodNumber, string? Room);
|
||||
|
||||
public record TimeEntryDto(
|
||||
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
||||
|
||||
/// <summary><see cref="MaterialPrompt"/> ist der beim letzten KI-"Übernehmen" gespeicherte
|
||||
/// Materialerstellungs-Prompt (4.5.20/4.5.36) — nur gesetzt, wenn die KI für diese Phase einen
|
||||
/// Medienvorschlag gemacht hatte. Ein MCP-Client kann ihn direkt zur Materialerzeugung nutzen, ohne
|
||||
/// erst den Desktop-Dialog öffnen zu müssen.</summary>
|
||||
public record LessonPhaseDto(
|
||||
Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand,
|
||||
string? MaterialPrompt);
|
||||
|
||||
public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes);
|
||||
|
||||
public record LessonDto(
|
||||
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||
string? Homework, string? PlanningIdeas, LessonStatus Status, List<string> Competencies,
|
||||
List<LessonPhaseDto> Phases, List<LessonAttachmentDto> Attachments);
|
||||
|
||||
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
|
||||
/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) -
|
||||
/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht;
|
||||
/// stattdessen deckelt <see cref="LessonPlanTools.MaxInlineAttachmentBytes"/> die Größe.</summary>
|
||||
public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content);
|
||||
|
||||
public record UnitDto(
|
||||
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
|
||||
UnitStatus Status, List<string> Competencies);
|
||||
|
||||
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||
|
||||
public record SubjectDto(Guid Id, string Name, string ShortName);
|
||||
|
||||
public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder);
|
||||
|
||||
public record CompetencyDomainDto(
|
||||
Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder,
|
||||
List<CompetencyItemDto> Items);
|
||||
|
||||
public record GroupMembershipDto(
|
||||
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||
|
||||
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
|
||||
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
|
||||
public record WriteResultDto(bool Applied, Guid? Id, string Message);
|
||||
|
||||
public record PlaceholderInfoDto(string Name, string Type, bool Required, bool IsConstant);
|
||||
|
||||
/// <summary>"Worksheets" aus der ursprünglichen Spec entsprechen im tatsächlichen Datenmodell den
|
||||
/// importierten Elternbrief-Vorlagen (<c>.lavorlage</c>, <c>LehrerApp.Templating</c>) — es gibt kein
|
||||
/// separates "Arbeitsblatt"-Konzept mit Fach/Klassenstufe-Metadaten. Siehe LetterTemplateTools.</summary>
|
||||
public record LetterTemplateDto(string Id, string Name, string Description, List<PlaceholderInfoDto> Placeholders);
|
||||
|
||||
public record LetterRenderResultDto(bool Success, string Message, string? Base64Pdf, string? SuggestedFileName);
|
||||
|
||||
/// <summary>Eine Zeile des Untis-Hub (siehe UntisHubService) - "Kind"/"DueState" als Text statt
|
||||
/// Enum-Wert, damit ein KI-Client sie ohne Kenntnis des internen Enums lesen kann. GroupId ist bei
|
||||
/// den drei dashboard-weiten Zeilen (offene Stunden, Klassenbuch-/Hausaufgabenabgleich) null - siehe
|
||||
/// UntisHubJobRow. Enthalten, damit ein KI-Client die Id nicht erst über get_groups nachschlagen
|
||||
/// muss, um get_untis_absence_rows/get_named_untis_absence_pattern für eine hier gelistete Gruppe
|
||||
/// aufzurufen (Nutzer-Feedback: die GroupId war nirgends exponiert).</summary>
|
||||
public record UntisHubStatusRowDto(
|
||||
string Kind, Guid? GroupId, string GroupName, string DueState, string DueLabel,
|
||||
DateTime? LastRunAt, string? LastResultSummary);
|
||||
|
||||
/// <summary>Lerngruppe (Klasse oder Kurs) - siehe GroupTools.GetGroups. Bewusst kein Verweis auf
|
||||
/// SubjectId->Name aufgelöst (dafür get_subjects), um nicht bei jeder Gruppe implizit einen ganzen
|
||||
/// Fach-Datensatz mitzuschleppen.</summary>
|
||||
public record GroupDto(
|
||||
Guid Id, string Name, GroupType Type, string SchoolYear, int GradeLevel,
|
||||
Guid? SubjectId, bool IsActive);
|
||||
|
||||
/// <summary>Anonymisierte Fehlzeiten-Diskrepanz (siehe UntisComparisonTools.GetUntisAbsenceRows):
|
||||
/// bewusst KEIN Schülername/keine Klasse - <see cref="RowId"/> ist die einzige Kennung, über die
|
||||
/// UntisComparisonTools.ApplyUntisAbsenceStatus später zurückordnet.</summary>
|
||||
public record UntisAbsenceRowDto(
|
||||
string RowId, DateOnly Date, string ReasonText, int AbsentMinutes, bool HandledOn,
|
||||
bool? ExternKeyInParentheses, string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary>Eine Zeile aus dem namentlichen Ausnahmeweg (get_named_untis_absence_pattern) - im
|
||||
/// Unterschied zu <see cref="UntisAbsenceRowDto"/> bewusst MIT Schülername, da genau diese
|
||||
/// Zusammenführung von Name und Fehlzeitendaten der Zweck des Aufrufs ist (z.B. Fehlmuster-Vergleich
|
||||
/// zwischen zwei Schülern) und der Nutzer sie je Anfrage einzeln freigegeben hat.</summary>
|
||||
public record NamedUntisAbsenceRowDto(
|
||||
Guid StudentId, string StudentFullName, DateOnly Date, string ReasonText,
|
||||
string CurrentLocalStatus, string CurrentGuessStatus);
|
||||
|
||||
/// <summary><see cref="Granted"/> ist false bei Ablehnung/Timeout oder wenn keine der angegebenen
|
||||
/// Schüler-IDs bekannt war - <see cref="Rows"/> ist dann null, nicht nur leer, damit ein KI-Client
|
||||
/// "abgelehnt" nicht mit "keine Fehlzeiten gefunden" verwechselt.</summary>
|
||||
public record NamedUntisAbsenceResultDto(bool Granted, string Message, List<NamedUntisAbsenceRowDto>? Rows);
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_exams" (Phase 1, siehe Planungsdokument).</summary>
|
||||
public class ExamTools(IExamRepository exams, IExamResultRepository examResults)
|
||||
{
|
||||
[Description("Listet Klausuren, optional gefiltert nach Lerngruppe.")]
|
||||
public List<ExamDto> GetExams(
|
||||
[Description("Optionale Lerngruppen-ID zum Filtern.")] Guid? groupId = null,
|
||||
[Description("Ergebnisse je Schüler mitliefern (Standard: nein, hält die Antwort klein).")] bool includeResults = false)
|
||||
{
|
||||
var list = groupId is { } id ? exams.GetByGroup(id) : exams.GetAll();
|
||||
return list.Select(e => new ExamDto(
|
||||
e.Id, e.GroupId, e.Title, e.Date, e.Status, e.Niveau,
|
||||
includeResults
|
||||
? examResults.GetByExam(e.Id)
|
||||
.Select(r => new ExamResultDto(r.StudentId, r.TotalPoints, r.Grade, r.Absent))
|
||||
.ToList()
|
||||
: null)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.ComponentModel;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
|
||||
/// Planungsdokument.</summary>
|
||||
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
||||
public List<GradeDto> GetGrades(
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Optionale Schüler-ID zum Filtern auf einen einzelnen Schüler.")] Guid? studentId = null)
|
||||
{
|
||||
var list = studentId is { } sid ? grades.GetByStudentAndGroup(sid, groupId) : grades.GetByGroup(groupId);
|
||||
return list.Select(g => new GradeDto(
|
||||
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
|
||||
}
|
||||
|
||||
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
|
||||
public async Task<WriteResultDto> CreateGradeEntry(
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
|
||||
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
|
||||
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
|
||||
[Description("Optionale Notiz.")] string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
if (student is null)
|
||||
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
|
||||
|
||||
var message =
|
||||
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
|
||||
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
|
||||
$", am {date:dd.MM.yyyy}" +
|
||||
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
|
||||
|
||||
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
var grade = new Grade
|
||||
{
|
||||
StudentId = studentId,
|
||||
GroupId = groupId,
|
||||
Category = category,
|
||||
Value = value,
|
||||
Date = date,
|
||||
Weight = weight,
|
||||
Note = note,
|
||||
};
|
||||
grades.Save(grade);
|
||||
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
|
||||
}
|
||||
|
||||
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
|
||||
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
|
||||
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
|
||||
{
|
||||
GradeCategory.Oral => "Mündlich",
|
||||
GradeCategory.Homework => "Hausaufgaben",
|
||||
GradeCategory.Participation => "Mitarbeit",
|
||||
GradeCategory.Project => "Projekt",
|
||||
GradeCategory.Other => "Sonstiges",
|
||||
_ => c.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
|
||||
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
|
||||
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
|
||||
public class GroupMembershipTools(
|
||||
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
|
||||
IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
|
||||
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
|
||||
[Description("Schüler-ID.")] Guid studentId,
|
||||
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
|
||||
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
|
||||
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
|
||||
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var student = students.GetById(studentId);
|
||||
var group = groups.GetById(groupId);
|
||||
if (student is null || group is null)
|
||||
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
|
||||
|
||||
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
|
||||
var target = existing is null
|
||||
? new GroupMembership { StudentId = studentId, GroupId = groupId }
|
||||
: Clone(existing);
|
||||
|
||||
var changes = new StringBuilder();
|
||||
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
|
||||
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
|
||||
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
|
||||
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
|
||||
|
||||
if (existing is not null && changes.Length == 0)
|
||||
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
|
||||
|
||||
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
|
||||
var message = $"{student.FullName} — {group.Name}" +
|
||||
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
|
||||
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
|
||||
|
||||
if (!await confirmation.ConfirmAsync(title, message, ct))
|
||||
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||
|
||||
memberships.Save(target);
|
||||
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
|
||||
}
|
||||
|
||||
private static GroupMembership Clone(GroupMembership m) => new()
|
||||
{
|
||||
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
|
||||
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
|
||||
};
|
||||
|
||||
private static string NiveauName(Niveau? n) => n switch
|
||||
{
|
||||
Core.Models.Niveau.E => "E-Niveau",
|
||||
Core.Models.Niveau.G => "G-Niveau",
|
||||
Core.Models.Niveau.Foerder => "Förderniveau",
|
||||
_ => "–",
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user