123 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 c128fc6741 fix: Kalenderraster mit einem gemeinsamen Faktor statt unabhängig in x/y skalieren
CI / build-and-test (push) Waiting to run
Breite und Höhe wurden bisher getrennt behandelt (Breite = volle reale
Boxbreite, Höhe = Größenwahl*Einheitenfaktor) - bei einer Box, deren
Seitenverhältnis vom Referenzentwurf abweicht, verzerrte das die Tageskacheln
sichtbar.

Das Raster wird jetzt komplett in einem festen 170mm-Referenzentwurf
berechnet und erst am Ende mit einem einzigen Faktor k = min(a_x, a_y,
Größenwahl) * millimeterScale in die Koordinaten der Vorlage skaliert - a_x/
a_y sind die Verhältnisse von realer Boxbreite/-höhe zum Referenzentwurf.
Dadurch bleiben die Kacheln immer proportional, auf Kosten von ungenutztem
Rand auf der Achse, die nicht der begrenzende Faktor ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 15:42:56 +02:00
adminandClaude Sonnet 5 4542f263fc fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
CI / build-and-test (push) Waiting to run
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:39:45 +02:00
adminandClaude Sonnet 5 86a97d91a7 fix: Anwesenheitskalender/Fehltagesliste nutzen die real deklarierte Boxgröße
CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170)
aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen
Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in
"pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template)
blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer
- sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF.

CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real
deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template
(LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die
Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:27:05 +02:00
adminandClaude Sonnet 5 763d7744be feat: Circle/RoundedRectangle-Zeichenbefehle im Canvas-Backend, Fehlzeitenkalender-Kacheln größer
CI / build-and-test (push) Waiting to run
Neue DrawingCommand-Primitives DrawCircle und DrawRoundedRectangle (SVG-Rendering
plus FLOWDRAWBOX-Paginierung). Anwesenheitskalender nutzt RoundedRectangle für die
Tageskacheln und verkleinert Monatsabstand/Kachel-Innenabstand, damit die Kacheln
den Rasterplatz sichtbar besser ausfüllen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:59:08 +02:00
adminandClaude Sonnet 5 77fe9b1c79 fix: Dokumentationsdialog wächst bei Elternbrief über den Bildschirm
CI / build-and-test (push) Waiting to run
SizeToContent="Height" ohne Begrenzung ließ das Fenster beim Typ
"Elternbrief" (drei zusätzliche Felder) so hoch werden, dass der
Speichern-Button auf kleineren Bildschirmen (z.B. MacBook) nicht mehr
erreichbar war. ScrollViewer bekommt eine MaxHeight, ab der intern
gescrollt wird - analog zum bestehenden Muster in McpConfirmDialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:12:53 +02:00
adminandClaude Sonnet 5 92e497b54f fix: Fehlzeitenkalender Spaltenbreiten und unnötigen Seitenumbruch
CI / build-and-test (push) Waiting to run
Datum-Spalte in der Fehltage-Tabelle war für "dd.MM.yyyy" zu schmal
bemessen (an der kurzen Kopfzeile "Datum" orientiert) und wurde
abgeschnitten. RenderFlow erzwang außerdem zwischen den Ausschnitten
einer zu hohen Zeichenbox einen harten Seitenumbruch unabhängig vom
tatsächlich verbleibenden Platz auf der Seite - dadurch begann z.B.
der zweite Monat auf Seite 3, obwohl Seite 2 noch reichlich Platz
hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:42:29 +02:00
admin 861f2c76ca fix: Umbruchlogik
CI / build-and-test (push) Canceled after 0s
2026-09-16 02:21:21 +02:00
admin ef3e9dbbb6 fix: Briefe Kontaktauswahl
CI / build-and-test (push) Canceled after 0s
2026-09-16 01:49:46 +02:00
admin 03a5e0dd9c fix: Fehlzeitenkalender in Templates, Datenbezug und Layout
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:58:09 +02:00
admin 6dccf96039 fix: Fehlzeitenkalender in Templates
CI / build-and-test (push) Canceled after 0s
2026-09-16 00:50:10 +02:00
admin 6e57bf407d feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s
2026-09-15 23:30:22 +02:00
admin 0b5cfc5522 feat: Feldeditor für Autofelder im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-09-15 11:07:37 +02:00
admin 09cbbf1b77 feat: Fehlzeiten Tabelle für Templates 2026-09-15 10:35:55 +02:00
admin 1671796844 feat: Fehlzeiten Calender 2026-09-15 10:02:33 +02:00
admin 70dc78904c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-14 00:54:43 +02:00
adminandClaude Sonnet 5 7b660c7152 feat: Planungsideen-Feld je Stunde + persistierter Materialerstellungs-Prompt
CI / build-and-test (push) Canceled after 0s
Lesson bekommt ein neues Freitextfeld PlanningIdeas fuer grobe Ideen vor der
Feinplanung, sichtbar im LessonDialog und als Kontext an die KI-Planung
(Backend + MCP) durchgereicht. LessonPhaseStep.MaterialPrompt speichert den
beim Uebernehmen einer KI-Stunde erzeugten Materialerstellungs-Prompt dauerhaft,
statt ihn nur einmalig im AiAssistDialog anzuzeigen - im Verlaufsplan-Editor
ueber einen neuen Kopieren-Button je Phase erneut nutzbar, auch ueber MCP
lesbar/schreibbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:41:13 +02:00
admin 3675b70004 feat: exclude group from untis hub
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:58:38 +02:00
admin eb1b2340b7 feat: expand teaching mode with live timeline and seating quick checks
CI / build-and-test (push) Canceled after 0s
2026-09-13 23:16:36 +02:00
adminandClaude Sonnet 5 b8193be6ec fix: Untis-Hub-Status ueber Geraete hinweg synchronisieren
CI / build-and-test (push) Canceled after 0s
UntisHubJobState (Faelligkeits-Zeitstempel des Untis-Hubs) lief bisher
ausserhalb des Sync - jedes Geraet fuehrte seine eigene Buchhaltung, wodurch
auf allen Instanzen dieselben Punkte offen blieben und ein bereits erledigter
Abgleich anderswo erneut WebUntis-Traffic ausgeloest haette (Nutzer-Feedback).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:24:03 +02:00
admin 0962845ead fix: startup speed
CI / build-and-test (push) Canceled after 0s
2026-09-13 22:04:07 +02:00
adminandClaude Sonnet 5 42c650518c MCP: TMPDIR-Auseinanderdriften zwischen Desktop und Bridge auf Mac/Linux behoben
LehrerApp.Desktop und LehrerApp.McpBridge emulieren die Named Pipe unter
macOS/Linux über eine Socket-Datei unter TMPDIR. Von Finder/Dock
gestartet bekommt Desktop das reguläre Session-TMPDIR unter
/var/folders/.../T, während die Bridge als Kindprozess von Claude
Desktop oft eine reduzierte Umgebung mit TMPDIR=/tmp erbt - beide
suchten die Pipe-Datei dann an unterschiedlichen Orten und fanden sich
nie ("MCP nicht erreichbar", obwohl beide Prozesse liefen). TMPDIR wird
jetzt in beiden Prozessen vor der ersten Pipe-Instanz hart auf denselben,
von ApplicationData abgeleiteten Ordner gesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:46:04 +02:00
adminandClaude Sonnet 5 2c791258a1 fix: GroupId ueber MCP auflösbar (get_groups + get_untis_hub_status)
CI / build-and-test (push) Canceled after 0s
Nutzer-Feedback aus echtem Live-Test der neuen Untis-MCP-Tools: groupId ist ueberall
Pflichtparameter (get_grades, get_schedule, get_lesson_plans, get_untis_absence_rows, ...), war
aber nirgends ueber MCP auflösbar - ein KI-Client kannte bestenfalls den Klarnamen einer
Lerngruppe, nie ihre Id.

- Neues GroupTools.cs mit get_groups (Read): listet Lerngruppen mit Id/Name/Typ/Schuljahr/
  Klassenstufe/SubjectId/IsActive, optional nach Schuljahr gefiltert.
- UntisHubStatusRowDto liefert zusaetzlich GroupId mit (null bei den drei dashboard-weiten
  Zeilen), damit fuer eine dort gelistete Gruppe nicht zusaetzlich get_groups noetig ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:13:13 +02:00
admin c528bd825f fix: mcp timeout
CI / build-and-test (push) Canceled after 0s
2026-09-13 00:38:05 +02:00
adminandClaude Sonnet 5 56ab5067e6 feat: Formulare-Menü mit Elternbrief-Einstieg + Arbeitsblatt-Personalisierung (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
Neue Menü-Kategorie "Formulare" in der Menüleiste: "Elternbrief erzeugen..." öffnet
jetzt einen Schüler-Picker statt nur aus der Schülerdetailansicht erreichbar zu sein,
"Arbeitsblatt personalisieren..." ist aktiv, sobald eine einzelne Lerngruppe geöffnet
ist, und erzeugt aus einer in TemplateDesigner gebauten .lavorlage-Vorlage ein PDF je
aktivem Gruppenmitglied - über eine von den Elternbrief-Vorlagen getrennte
WorksheetTemplateStore-Bibliothek.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 00:21:08 +02:00
adminandClaude Sonnet 5 bfe214ecf2 feat: Untis-Hub-Kadenzkopplung, anpassbarer Statusvorschlag + KI-/MCP-Unterstuetzung beim Fehlzeitenabgleich (Nutzer-Feedback)
CI / build-and-test (push) Canceled after 0s
- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die
  kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt).
- Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt
  mit dem berechneten Vorschlag, aber frei aenderbar.
- Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync:
  gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/
  Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung.
- Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/
  apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie
  get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer
  explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer
  IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert).
- MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper
  gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 22:55:33 +02:00
adminandClaude Sonnet 5 d979a84e7b feat: LehrerApp.McpBridge zu resilientem MCP-Proxy umgebaut
Claude Desktop deaktivierte den ganzen MCP-Server, sobald der Initialize-Handshake beim
Bridge-Start fehlschlug - der Regelfall, wenn LehrerApp.Desktop (z.B. nach Windows-Autostart
von Claude Desktop) noch nicht läuft. Die Bridge ist jetzt selbst ein MCP-Server gegenüber
Claude Desktop und gleichzeitig ein MCP-Client gegenüber dem echten Server in LehrerApp.Desktop:
der Handshake gelingt dadurch immer.

Verbunden spiegelt sie die echte Werkzeugliste 1:1; ohne Verbindung bietet sie nur ein
lehrerapp_status-Werkzeug an, das den Grund erklärt und erneut verbindet. Ein Hintergrund-Loop
versucht unabhängig davon alle 5s zu reconnecten und schaltet per notifications/tools/list_changed
automatisch auf die echten Werkzeuge um, sobald LehrerApp erreichbar ist. Bricht die Verbindung
während eines laufenden Aufrufs ab, kommt ein normales Tool-Fehlerergebnis statt eines
Prozessabsturzes zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:45:03 +02:00
adminandClaude Sonnet 5 3f8813df51 feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)
Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain,
add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und
delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb).

Lesson bekommt ein neues Competencies-Feld (analog zu Unit.Competencies) sowie
add_lesson_competency/remove_lesson_competency in LessonPlanTools.cs.

ICompetencyDomainRepository um GetBySubject() erweitert, um den Katalog eines Fachs ohne
bekannte Klassenstufe abzufragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 20:01:23 +02:00
adminandClaude Sonnet 5 19aa302487 feat: Sitzungsfreigabe für MCP-Bestätigungen + LessonStatus.Cancelled
Sitzungsfreigabe: McpConfirmDialog bietet zwei Checkboxen ("diese Art
von Aktion" / "alle KI-Aktionen für den Rest der Sitzung nicht mehr
nachfragen"), nur beim Bestätigen wirksam. IMcpConfirmationService.
ConfirmAsync bekommt einen per [CallerMemberName] automatisch
befüllten operationKey - kein bestehender Aufruf musste geändert
werden. Freigaben leben als In-Memory-Bookkeeping auf der
Confirmation-Service-Instanz, gelten bis App-Ende, werden aber trotzdem
geloggt, damit stillschweigende Bestätigungen nicht spurlos bleiben.
Grund: 17 identische Bestätigungen in Folge für eine neu generierte
Unterrichtseinheit sind unzumutbar.

LessonStatus.Cancelled ("Ausgefallen") als Alternative zu
delete_lesson für Stunden, die nur ausgefallen sind (Exkursion,
Feiertag), aber als Ereignis dokumentiert bleiben sollen. Als
Cancelled=4 angehängt (bestehende LiteDB-Werte 0/1 sind fix). Konsistent
wie "bereits durchgeführt" behandelt an allen Stellen, die bisher nur
auf Conducted prüften: Terminverschiebung, Fortschrittsanzeige,
Hausaufgabe-kontrollieren-Erinnerung, "nächste Stunde"-Vorschlag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 02:25:45 +02:00
adminandClaude Sonnet 5 a4733c156b feat: prominenterer Bestätigungsdialog + move_lesson/delete_lesson (Nutzer-Feedback)
Nach dem ersten Live-Test mit echtem Bridge-Prozess: der bisherige
ConfirmDialog fiel zu wenig auf, wenn LehrerApp im Hintergrund lief
(Normalfall, da der Anstoß vom KI-Client in einem anderen Fenster
kommt). Neuer, eigenständiger McpConfirmDialog statt Änderung am
geteilten ConfirmDialog (hätte alle anderen Aufrufer mitbetroffen):
breiter, auffälliger Kopfbereich, Topmost. AvaloniaMcpConfirmationService
holt das Hauptfenster zusätzlich aus einer möglichen Minimierung und
aktiviert es vor dem Anzeigen.

move_lesson kapselt die bereits vorhandene LessonSchedulingService.Move
(shiftFollowingLessons öffnet eine Lücke für eine neue Stunde, indem
spätere Stunden derselben Einheit mitverschoben werden) - keine neue
Terminlogik, nur Wiederverwendung.

delete_lesson ist eine bewusste, gezielte Ausnahme von "v1 ohne
Lösch-Tools" auf expliziten Nutzerwunsch: eigene
AllowedDestructiveWriteTools-Liste, Destructive=true-Annotation,
Bestätigungstext betont ausdrücklich die fehlende Papierkorb-Deckung
für Lessons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:43:59 +02:00
adminandClaude Sonnet 5 e6c30b0bc7 feat: MCP-Tool add_lesson_attachment (Nutzer-Nachtrag)
CI / build-and-test (push) Canceled after 0s
download_lesson_attachment deckte bisher nur die Leserichtung ab, der
Nutzer legt Arbeitsblätter aber tatsächlich über die Anhang-Funktion an
Einzelstunden ab. Neues Write-Tool nimmt Dateiname + Base64-Inhalt
entgegen, validiert vor der Bestätigungsnachfrage (leer/ungültig/zu
groß) und lädt erst nach Bestätigung hoch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:45:56 +02:00
adminandClaude Sonnet 5 55fba2cadb feat: lokaler MCP-Server, Phase 4 (Elternbrief-Vorlagen + Claude-Desktop-Registrierung)
Schließt die MCP-Server-Spec ab. "Worksheets" aus der Spec entsprechen
im tatsächlichen Datenmodell den .lavorlage-Elternbrief-Vorlagen
(LehrerApp.Templating) - es gibt kein separates Arbeitsblatt-Konzept mit
Fach/Klassenstufe-Metadaten. Neue Tools list_letter_templates (Read) und
render_letter (Read, liefert Base64-PDF, kein DB-Schreibzugriff).

upload_worksheet/update_worksheet bewusst nicht umgesetzt: das
Seitenlayout ist eine eigene positionsbasierte DSL mit eigenem
visuellen Editor (LehrerApp.TemplateDesigner) - ein LLM müsste sie
blind erzeugen, mit hohem Risiko für kaputte Layouts. Platzhalter-Logik
aus CreateLetterDialogViewModel nach LetterPlaceholderBuilder extrahiert,
damit Dialog und MCP-Tool nicht auseinanderdriften.

Neuer McpClientRegistrationService trägt den Bridge-Pfad in Claude
Desktops claude_desktop_config.json ein (Button in den Einstellungen,
nie automatisch), ohne bestehende Fremdeinträge zu verlieren und ohne
eine nicht lesbare Konfigurationsdatei zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:53:46 +02:00
adminandClaude Sonnet 5 dd2e1e7c61 feat: lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools)
Statt eines "update_lesson", das die ganze Stunde inkl. Verlaufsplan als
ein großes JSON-Objekt tauscht, gezielte kleine Tools je Teiloperation
(Nutzervorschlag): create/update_unit, create/update_lesson (Metadaten
ohne Phasen), add/update/remove_lesson_phase (je eine Phase),
download_lesson_attachment (Base64, auf 3 MB gedeckelt). Verkürzt das
Lesen-Schreiben-Zeitfenster je Operation und liefert lesbare Diffs für
den Bestätigungsdialog statt eines Objekt-Dumps.

ILessonRepository um GetById ergänzt (fehlte bisher, war aber
Voraussetzung für jedes der neuen Tools). get_lesson_plans liefert jetzt
zusätzlich Phase-IDs und Attachment-Metadaten, damit ein Client sie
gezielt referenzieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:52:48 +02:00
adminandClaude Sonnet 5 0c60a54c4d build: LehrerApp.McpBridge ins macOS-App-Bundle aufnehmen
build-macos-app.sh publiziert und platziert jetzt zusätzlich zur
Hauptapp die MCP-Bridge-Binary (Contents/MacOS/LehrerApp.McpBridge),
inklusive Universal-Binary-Handling und eigenständiger Ad-hoc-Signierung
vor dem Bundle-weiten codesign --deep-Schritt. Publish/Lipo/Kopier-Logik
dafür in eine wiederverwendbare place_binary()-Funktion extrahiert.

Nur auf Bash-Syntax geprüft (kein macOS in dieser Session verfügbar) -
noch nicht auf echtem macOS gebaut/verifiziert, siehe TODO.md 4.5.27.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:23:45 +02:00
adminandClaude Sonnet 5 9567d8d616 feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und
update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt
zuerst einen menschenlesbaren Bestätigungsdialog (bestehender
ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread
angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout
gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans.

create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden
Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/
update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt
(siehe TODO.md 4.5.26).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 20:14:51 +02:00
adminandClaude Sonnet 5 98f5573999 feat: lokaler MCP-Server, Phase 1 (Infrastruktur + Read-Tools)
Erlaubt einem lokalen KI-Client (z.B. Claude Desktop) strukturierten
Lesezugriff auf Schüler, Klausuren, Noten, Stundenplan und Zeiterfassung.
Neuer LehrerApp.McpBridge-Prozess reicht stdio-JSON-RPC über eine Named
Pipe an einen In-Process-MCP-Server im Avalonia-Hauptprozess durch
(ModelContextProtocol.Core, StreamServerTransport direkt auf der Pipe).
Standardmäßig deaktiviert, Opt-in über neuen Einstellungen-Tab.
Dokumentationstypen (Gesprächsnotizen/Vorfälle/Förderpläne) sind auf
Code-Ebene nie erreichbar (McpToolScope, analog PlainEventStore.Allowed).

Write-Tools, Bestätigungsdialog-UI, Worksheets/Lesson-Plans-Tools und
macOS-Packaging folgen in späteren Phasen (siehe TODO.md 4.5.25).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 19:59:47 +02:00
admin 455c61c946 fix: Uhrzeit-Wraparound-Flakiness in MissingTeachingTime-Tests
CI / build-and-test (push) Canceled after 0s
TimeOnly.FromDateTime(DateTime.Now).AddHours(+-2) wickelt bei Mitternacht um (TimeOnly hat
keinen Datumsanteil) - je nach Ausfuehrungsuhrzeit (ca. 22:00-02:00) landete "+2 Stunden"
zeitlich vor statt nach "jetzt" oder umgekehrt, wodurch zwei Tests zufaellig rot wurden statt
das eigentliche Verhalten (Wartezeit nach Unterrichtsende) zu pruefen. Per Gegenprobe auf
unveraendertem main bestaetigt: dieselbe Flakiness besteht dort bereits.

DashboardViewModel erhaelt einen optionalen Func<DateTime>? now-Konstruktorparameter (Default
DateTime.Now, gleiches Muster wie die bereits bestehenden optionalen annualPlanEvents/
schoolWeather-Parameter) und nutzt ihn statt direkter DateTime.Now-Aufrufe in Load() und
LoadMissingTeachingTime. Die beiden betroffenen Tests injizieren jetzt einen festen
Referenzzeitpunkt (ein Feiertags-freier Dienstag im September) und sind damit unabhaengig
von der tatsaechlichen Ausfuehrungsuhrzeit deterministisch gruen.

Details siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 01:19:29 +02:00
admin 6af4bee1f0 refactor: Dashboard-Handlungsbedarf zu einer Karte zusammenfassen
Fasst die sieben Kacheln, die tatsaechlich "wo muss ich reagieren" beantworten
(Entschuldigungen, Fehlzeiten-Warnung, Foerderplan-Wiedervorlage, Korrekturen,
ungeplante Stunden, Auffaelligkeiten, Unterrichtszeit-Nacherfassung), zu einer
Karte "Handlungsbedarf" mit Filter-Chips zusammen statt sieben eigener
Sichtbarkeits-Schalter.

Neues gemeinsames Modell AttentionItem/AttentionGroup/AttentionAction traegt nur
Anzeigedaten - die Datenbeschaffung bleibt unveraendert in den jeweiligen
DashboardViewModel.LoadXxx-Methoden, die am Ende ein AttentionItem statt ihrer
eigenen Item-Klasse erzeugen. RebuildAttention() gruppiert nach Art (feste
Reihenfolge) und wendet Filter an, ohne die Repos erneut abzufragen.

SupportPlanDueItem, CorrectionProgressItem, UnplannedLessonItem und
DashboardAlertItem entfallen (nur dashboard-intern verwendet); OpenExcuseItem
und AttendanceWarningItem bleiben bestehen, da GroupOverviewViewModel sie
weiterhin nutzt. Fuenf Navigations-Commands wurden durch ein einziges
OpenAttentionItemCommand ersetzt.

Bewusste Verhaltensaenderung: LoadAlerts dupliziert Fehlzeiten-Ueberschreitungen
nicht mehr in die Auffaelligkeiten-Gruppe, da dieselbe Zahl sonst zweimal in
derselben Karte erschiene (vorher durch zwei getrennte Kacheln nicht sichtbar).

Details und Testanpassungen siehe TODO.md, Abschnitt 9 (Dashboard).
2026-09-11 00:40:58 +02:00
admin c3ce1a7204 fix: Dashboard-Kachelraster - Luecken und falsche Margins bei ausgeblendeten Kacheln
ApplyCardLayout() zaehlte bisher IsVisible statt EffectiveIsVisible, wodurch eine
eingeschaltete aber leere HideWhenEmpty-Kachel weiterhin einen Rasterplatz belegte.
Da im Alltag meist mehrere der sieben HideWhenEmpty-Kacheln leer sind, war das der
Normalfall, nicht die Ausnahme. UpdateDashboardSummary() setzte zudem alle IsEmpty-
Werte, ohne das Layout danach neu zu berechnen.

Zusaetzlich hatte jede Kachel ihren Grid-Margin fest im XAML verdrahtet (links/rechts),
obwohl Spalte und Zeile erst zur Laufzeit aus Sichtbarkeit und Reihenfolge berechnet
werden - beim Ausblenden einer Kachel wanderten die Nachbarn in die andere Spalte,
der Rinnstein sass dann auf der falschen Seite. DashboardCardOption.Margin leitet den
Wert jetzt aus Column ab; das XAML bindet darauf statt fixer Werte.

Drei Regressionstests decken beide Faelle ab.
2026-09-11 00:12:10 +02:00
adminandClaude Sonnet 5 ba52109eaa feat: Untis-Hub für WebUntis-Sync-Gesundheitsstatus
CI / build-and-test (push) Canceled after 0s
Zeigt die Fälligkeit der vier bestehenden, manuell ausgelösten WebUntis-Abgleiche
(Fehlzeiten je Lerngruppe, offene Stunden, Klassenbuch-, Hausaufgabenabgleich) in
einem neuen Hub-Fenster, ohne selbst WebUntis anzufragen - nur gespeicherte
Zeitstempel werden ausgewertet. Konsolidiert die bisher verstreuten Einstiegspunkte
(Sidebar-Button, Dashboard-Buttons) in ein neues WebUntis-Menü plus einen
kompakten Gesundheits-Indikator auf dem Dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 23:41:12 +02:00
admin bd0d7e47ea fix: UntisLogo
CI / build-and-test (push) Canceled after 0s
2026-09-09 00:07:39 +02:00
admin 2ec8adac61 Webuntis stunden check
CI / build-and-test (push) Canceled after 0s
2026-09-08 23:59:07 +02:00
admin e86125e5f9 Hausaufgaben feature und Stundeplan fix
CI / build-and-test (push) Canceled after 0s
2026-09-08 18:01:42 +02:00
admin 86e114580f Fix: Sync Log und verwaise Einträge
CI / build-and-test (push) Canceled after 0s
2026-09-07 17:09:38 +02:00
admin 835cccadec ki Backend fix - Answer Size
CI / build-and-test (push) Canceled after 0s
2026-09-07 12:45:50 +02:00
admin 709ea88c2a Arbeitszeitnacherfassung
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:51:17 +02:00
admin 73453fd88d Add Feature: Monatsübersicht Fehlzeiten Widget
CI / build-and-test (push) Canceled after 0s
2026-09-06 13:27:52 +02:00
admin 265190b3aa UI Improvements: Planungsdialog, Fehlzeitendarstellung, Vorauswahl der Einheit
CI / build-and-test (push) Canceled after 0s
2026-09-05 00:02:28 +02:00
admin fedfceb81d KI-Backend: Antwortretten - Funktion
CI / build-and-test (push) Canceled after 0s
2026-09-04 12:10:42 +02:00
admin 7ef0c95a73 Optimierung des Startvorgangs, lazy load, async im thread pool oder während des Startbildschirms vor dem Mainwindow.
CI / build-and-test (push) Canceled after 0s
2026-09-02 21:46:01 +02:00
adminandClaude Sonnet 5 94946ffefb Stundenplan: verwaiste Stunden per "FixIt"-Button verknüpfen
CI / build-and-test (push) Canceled after 0s
Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer
(kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne
Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer
tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen"
an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog
sucht stattdessen nach einer passenden vorhandenen Stunde und hängt
sie auf den geklickten Termin um.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:27:06 +02:00
admin 3cccb3226c Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
2026-09-02 20:09:24 +02:00
adminandClaude Sonnet 5 988a293c42 Planung: Stunde einer anderen Einheit zuweisen ("Einheit wechseln")
Bisher war die Einheit einer Stunde nach dem Anlegen fix - bei falscher
Auswahl im Erstellen-Dialog blieb nur Löschen+Neuanlegen. Neuer Button
öffnet einen Dialog zur Auswahl der Ziel-Einheit; die Ansicht springt
danach automatisch dorthin mit der Stunde vorausgewählt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 20:07:53 +02:00
adminandClaude Sonnet 5 92f9a75ec1 E-Mail-Ordner-Triage: eM Client 11 MCP-Support als bevorzugten Weg ergänzen
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 18:59:41 +02:00
adminandClaude Sonnet 5 0a419c4c11 WebUntis: Browser-Header gegen HTTP 403, Klassenlehrer-Reports on-demand
CI / build-and-test (push) Canceled after 0s
Fehlzeiten-/Klassenbuch-Reports (reports.do, api/polling/REPORT) sowie
der JSON-RPC-Login schickten bisher keinen User-Agent, Referer oder die
übrigen Header, die WebUntis' eigene WebApp immer mitsendet - führte zu
HTTP 403. Header 1:1 aus einem echten Browser-Request übernommen und
zusätzliche Set-Cookie-Werte (z.B. Tenant-Id) aus der Login-Antwort
eingesammelt statt nur JSESSIONID/schoolname. Zusätzlich eine feste
kurze Pause zwischen Login und erstem Report-Request.

Die Klassenlehrer-Übersicht holt WebUntis-Daten jetzt nicht mehr eager
beim Navigieren oder bei jedem Sync-Ereignis, sondern nur noch über den
bestehenden "Aktualisieren"-Button - vermeidet spürbare Startlatenz und
unnötige WebUntis-Last. Eingehende Sync-Ereignisse (z.B. ein auf einem
anderen Gerät geschlossener Vorgang) bauen die Ansicht per
RefreshFromLocalDataOnly() weiterhin sofort neu auf, aber ausschließlich
aus den zuletzt geholten WebUntis-Daten plus aktuellen lokalen Daten -
ohne selbst je einen WebUntis-Request auszulösen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:41 +02:00
adminandClaude Sonnet 5 216708e879 Gesamtbild: konfigurierbare Gewichtung, Dämpfung nach Vorgang, Rückfall-Warnung
CI / build-and-test (push) Canceled after 0s
Feste Score-Gewichte durch einen Einstellungsdialog ersetzt. Ein
geschlossener Vorgang dämpft ab seinem Schließ-Datum den zu seinen
Schlagwörtern passenden Score-Bereich (Anwesenheit/Verspätung/
Klassenbuch), ohne Schlagwort-Treffer vorsichtshalber alle drei — die
faktische Fehlquote bleibt davon unberührt. Verschlechtert sich die Lage
danach wieder spürbar, erscheint eine "erneut auffällig"-Warnung im
bestehenden "Muster erkannt"-Panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:52:38 +02:00
adminandClaude Sonnet 5 0d5fb6d146 Vorgänge: verknüpfte Dokumentation direkt zum Nachlesen öffnen
CI / build-and-test (push) Canceled after 0s
"Bearbeiten"-Button an jedem verknüpften bzw. verknüpfbaren Eintrag
öffnet den bestehenden Dokumentations-Dialog, ohne den Vorgänge-Tab zu
verlassen — bisher musste man dafür in den Schülerdatensatz wechseln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 23:08:59 +02:00
admin 1d9521fc60 SplashScreen
CI / build-and-test (push) Canceled after 0s
2026-09-01 22:51:50 +02:00
admin 4af83c3e68 Update Launch.json 2026-09-01 22:43:10 +02:00
adminandClaude Sonnet 5 15463bc292 "Vorgang": Fallmappe für Klassenbuch- und Dokumentationseinträge
CI / build-and-test (push) Canceled after 0s
Neuer 4. Tab in der Klassenlehreransicht: bündelt Titel, Beschreibung,
Schlagwörter, verknüpfte Dokumentation und angeheftete (eingefrorene)
WebUntis-Klassenbucheinträge zu einem laufenden Problem mit einer/einem
oder mehreren Schüler*innen. Anheften direkt aus dem Klassenbuch-Tab per
Rechtsklick. Sync-fähig nach dem bestehenden Documentation-Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 22:42:06 +02:00
adminandClaude Sonnet 5 987b49e761 Klassenlehrer: faire Fehlquote, Muster-Score statt Heute-Snapshot
CI / build-and-test (push) Canceled after 0s
Verspätung zählt in der Fehlquote nicht mehr wie ein voller Fehltag,
sondern separat mit reduziertem Gewicht. Neuer "Gesamtbild"-Reiter
(Standardansicht) sortiert die Klasse nach einem kumulierten
Auffälligkeits-Score übers Schuljahr statt nur nach dem heutigen Status,
und bezieht dabei auch Klassenbucheinträge anderer Lehrkräfte ein
(Schlüsselwörter für Hausaufgaben/Mitarbeit, "suspendiert" schießt sofort
deutlich hoch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 21:36:34 +02:00
adminandClaude Sonnet 5 fb7a015b42 CalendarDatePicker: DateTimeOffset<->DateTime-Konvertierung per Converter
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist (WPF-Toolkit-Ursprung) DateTime?,
nicht DateTimeOffset? wie beim eingebauten DatePicker - Avalonia bietet
dafuer keine automatische Konvertierung. Ohne Converter blieb das Feld
leer und ein Klick auf einen Kalendertag warf eine InvalidCastException.
Betraf auch die schon bestehenden Bindings in WithdrawStudentDialog und
CreateLetterDialog, nur bislang unbemerkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:09:08 +02:00
adminandClaude Sonnet 5 09e97efb61 CalendarDatePicker: DateTimeOffset-Felder nullbar machen
CI / build-and-test (push) Canceled after 0s
CalendarDatePicker.SelectedDate ist DateTimeOffset?, die per DatePicker-
zu-CalendarDatePicker-Umstellung betroffenen ViewModel-Eigenschaften
waren aber nicht-nullbares DateTimeOffset. Der alte DatePicker tolerierte
das, CalendarDatePickers Bindung nicht - Feld blieb leer und ein
Konvertierungsfehler wurde beim Auswählen geworfen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:19:07 +02:00
adminandClaude Sonnet 5 d297f9f109 Klassenbuch-Tab zum Dokumentations-Hub ausgebaut
CI / build-and-test (push) Canceled after 0s
Umschalter zwischen WebUntis-Klassenbuch und eigener Dokumentation statt
Merge beider strukturell unterschiedlicher Datensätze, mit Zahlen-Badges
je Quelle (kritisch/Nacharbeit hervorgehoben) im Tab und an der
"Klassenbuch öffnen"-Aktion der Übersicht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 17:05:13 +02:00
admin de6d4236a2 Merge branch 'claude/epic-murdock-f9aa7b'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	TODO.md
2026-09-01 15:34:41 +02:00
admin edf7510765 Datepicker update
CI / build-and-test (push) Canceled after 0s
2026-09-01 15:31:16 +02:00
adminandClaude Sonnet 5 2ccc3201d2 DatePicker durch CalendarDatePicker ersetzen (Zeitraum-Datumsfelder)
Avalonias Spinner-DatePicker übernimmt Änderungen nur über einen separaten
Häkchen-Button im Flyout und verwirft sie sonst stillschweigend. Betrifft
alle verbliebenen Zeitraum-Datumsfelder mit diesem Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:46:53 +02:00
admin ab1feba0f0 Merge remote-tracking branch 'origin/main'
CI / build-and-test (push) Canceled after 0s
# Conflicts:
#	LehrerApp.TemplateDesigner.Tests/ProjectLifecycleTests.cs
#	LehrerApp.TemplateDesigner/DesignerViewModel.cs
#	LehrerApp.TemplateDesigner/MainWindow.axaml
#	LehrerApp.Templating/LayoutParser.cs
#	LehrerApp.Templating/QuestTemplateRenderer.cs
2026-09-01 10:15:54 +02:00
admin b2d0ccd30d TemplateDesigner: Redesign des TemplateSkripts 2026-09-01 10:07:25 +02:00
admin c6efdab9ef TemplateDesigner: SystemVars
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:47:48 +02:00
admin 11ef73ab1f TemplateDesigner: pdf-Export
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:28:08 +02:00
admin 4b4a746e17 Update im KI-Backend für pdf-import
CI / build-and-test (push) Canceled after 0s
2026-08-31 23:20:19 +02:00
admin e2e2bfb854 feat: add external drawing boxes and paged callbacks
CI / build-and-test (push) Canceled after 0s
2026-08-31 22:10:06 +02:00
admin 3e5f197bdb feat: add flowing templates and PDF import pipeline
CI / build-and-test (push) Canceled after 0s
2026-08-31 19:56:25 +02:00
adminandClaude Sonnet 5 95e3f677e4 Klassenlehrer: Fehlquote nutzt echten WebUntis-Ferienkalender + Sommerferien-Näherung, Diagnose-Logging
CI / build-and-test (push) Canceled after 0s
Nutzer-Verifikation deckte auf, dass WebUntis' getHolidays für dieses Konto nie einen
Sommerferien-Eintrag liefert (11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-Zeitraum
dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei Schuljahres-Datensätzen
liegen statt in einem. Die reine Ferienkalender-Abfrage aus dem letzten Commit kann diese Lücke
also nicht schließen: der frühere heuristische Ansatz (frühester Fehlzeiten-Eintrag der Klasse als
Näherung für den ersten Unterrichtstag) ist deshalb zusätzlich wieder da, kombiniert mit dem echten
Ferienkalender für den Rest des Jahres.

Herkunft des Nenners jetzt im Tooltip sichtbar (angenommener Unterrichtsbeginn, abgezogene
Ferientage) statt nur intern verrechnet, plus Logging jedes Ferienabrufs (Erfolg oder Fehler) -
sonst wäre der falsche Wert wieder unbemerkt geblieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 13:21:16 +02:00
adminandClaude Sonnet 5 cdbed69d8b Klassenlehrer: Verspätungen nicht mehr als fehlende Entschuldigung, echter Ferienkalender für Fehlquote
CI / build-and-test (push) Canceled after 0s
Verspätungen sind i.d.R. nicht entschuldigungsfähig - "Offene Entschuldigungen" erinnert dafür
nicht mehr, stattdessen eskaliert die Mustererkennung ab 5 Verspätungen im Schuljahr zu einem
Elterngespräch/Brief-Hinweis mit Wiedervorlage-Option.

Die Jahresfehlquote nutzte bislang den 1. August als fest verdrahteten Schuljahresbeginn und
zählte damit noch laufende Sommerferien als Schultage mit, was die Quote kurz nach
Schuljahresbeginn stark verfälschte (~57% statt ~100% bei durchgehend fehlenden Schülern). Nutzt
jetzt den echten WebUntis-Ferienkalender (GetHolidaysAsync, gecacht in WebUntisSettingsService).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 11:56:35 +02:00
admin 527c186090 Add constant rich text placeholders
CI / build-and-test (push) Canceled after 0s
2026-08-30 23:23:48 +02:00
adminandClaude Sonnet 5 28469bf547 E-Mail-Ordner-Triage als offenen Punkt in TODO.md skizzieren
CI / build-and-test (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:55:57 +02:00
admin 9dce6576e2 TemplateDesigner: UI update
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:47:53 +02:00
admin 9f28081931 Fix Platzhalter Editer im TemplateDesigner
CI / build-and-test (push) Canceled after 0s
2026-08-30 22:30:26 +02:00
admin 6468596bf3 Add designer file menu and package metadata
CI / build-and-test (push) Canceled after 0s
2026-08-30 20:49:13 +02:00
adminandClaude Sonnet 5 23abe7af63 Dashboard-Erinnerung für fehlende Unterrichtszeit-Erfassung
CI / build-and-test (push) Canceled after 0s
Neue Karte "Unterrichtszeit nacherfassen" schaut 14 Tage zurück und
listet Schultage mit Unterricht laut Stundenplan ohne zugehörigen
"Unterricht"-Zeiteintrag (Ferien/Feiertage/komplett ausgefallene Tage
ausgenommen). Für heute erscheint der Eintrag erst 30 Minuten nach dem
laut Stundenplan letzten Unterrichtsende. Klick auf "Erfassen" öffnet
den bestehenden Nacherfassen-Dialog vorbelegt mit Datum, Kategorie
"Unterricht" und berechnetem Zeitfenster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 19:38:57 +02:00
admin 3f71f7f371 Add visual template coordinate editor 2026-08-30 19:29:06 +02:00
admin 2f2761caf8 QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s
2026-08-30 15:37:22 +02:00
adminandClaude Sonnet 5 a088823b30 Notentendenzen (3+/2-) korrekt in Notenschnitt einrechnen, Abschnittsende-Vorschlag aus Einheit
CI / build-and-test (push) Canceled after 0s
Bisher liess int.TryParse jede Note mit Tendenz durchfallen und WeightedAverage rechnete den
betroffenen Bereich mit 0 statt sie auszulassen - faelschte Notenuebersicht, Zielnoten- und
Was-waere-wenn-Rechner. Ausserdem schlaegt der Mitarbeits-Assistent beim Abschnitt-Abschliessen
jetzt das Ende der zuletzt abgeschlossenen Unterrichtseinheit statt immer "heute" vor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:24:16 +02:00
adminandClaude Sonnet 5 1215d4ac22 Tagesflagge im Sitzplan, Leistungsüberblick mit Zielnoten-Rechner
CI / build-and-test (push) Canceled after 0s
- Mitarbeit: Tagesflagge (👑 Spitzentag / 😴 Schlaftag /  Schlechter Tag)
  je Schüler und Sitzung. Primär im Sitzplatz-Dialog (⇧1/2/3, ⇧X) mit
  Badge auf der Sitzplatz-Kachel; Fallback in der Schnelleingabe für
  Lerngruppen ohne Sitzplan.
- Noten: neuer Dialog "Überblick" in der Notenübersicht zeigt Klausuren,
  Mitarbeit- und sonstige Noten eines Schülers samt berechneter
  Zeugnisnote. Zielnoten-Rechner (ReportGradeTargetCalculator) beantwortet
  "was brauche ich noch für Note X", ein Was-wäre-wenn-Rechner simuliert
  eine zusätzliche Klausurnote. Bewerter-/Schülermodus per Umschalter im
  selben Fenster — Bewertermodus zeigt zusätzlich Kursdurchschnitt je
  Klausur und erlaubt das Bearbeiten von Mitarbeit-/Sonstige-Noten.
  Klausurverlauf als Balken-Sparkline wie die bestehende Notenentwicklung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:43:47 +02:00
adminandClaude Sonnet 5 84c03962ef Klausurschnitt-Vorjahresvergleich beim Hochstufen
CI / build-and-test (push) Canceled after 0s
LearningGroup.PreviousGroupId wird von GroupRolloverService jetzt automatisch
gesetzt; neue Karte "Vorjahresvergleich" im Kurs-Tab Übersicht zeigt den
gepoolten Klausurdurchschnitt neben dem der Vorgängergruppe, sobald dort
Ergebnisse vorliegen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:33:07 +02:00
admin b7c7d46ee4 Doppelstunden im Stundenplan korrekt anzeigen 2026-08-30 02:29:50 +02:00
adminandClaude Sonnet 5 da4b88c93d Persönliche Klausurlast pro Woche, Mitarbeit-Reset und Sitzplan-Strichliste
CI / build-and-test (push) Canceled after 0s
- Klausuren: neuer ExamWeekLoadService zählt eigene Klausuren je ISO-Woche
  über alle Kurse hinweg (Klassen-Kollisionen deckt bereits der externe
  Klausurplaner ab). Hinweis direkt im ExamDialog beim Datum, zusätzlich
  neue Dashboard-Karte "Klausurwochen" für die 60-Tage-Vorausschau.
- Mitarbeit: Schnelleingabe-Dialog kann eine Bewertung jetzt per Entf auf
  "nicht bewertet" zurücksetzen (bisher nur im Sitzplatz-Dialog möglich).
- Sitzplan: Strichliste "gemeldet"/"gemeldet und drangekommen" direkt auf
  der Sitzplatz-Kachel (ParticipationEntry.RaisedHandCount/CalledOnCount);
  schlägt im Sitzplatz-Dialog eine Quantitätsbewertung vor (ein Klick zum
  Übernehmen, nie automatisch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 02:11:20 +02:00
adminandClaude Sonnet 5 538ad65a3a Dashboard-Kalender: Tageszahlen zentrieren, Karte nicht mehr strecken
Wochentag-Header waren zentriert, die Tageszahlen darunter aber
faktisch linksbündig (Button ohne HorizontalAlignment wurde in der
UniformGrid-Spalte gestretcht). Zusätzlich wurde die Kalenderkarte bei
breiten Fenstern über die ganze 2*-Spalte gezogen; MaxWidth+Left-Align
begrenzt sie auf ihre Inhaltsbreite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 01:06:23 +02:00
admin dade41ee8d Stundenplan verbesserung
CI / build-and-test (push) Canceled after 0s
2026-08-30 00:53:32 +02:00
adminandClaude Sonnet 5 2b299fc940 Dashboard-Performance, CI-Workflow, Settings-Aufteilung, Backup-Härtung
- Dashboard: Fehlzeiten-Warnung lädt Mitarbeitssitzungen einmal vorab
  statt pro Schüler/Eintrag einzeln nachzuschlagen (N+1 vermieden);
  neues IParticipationSessionRepository.GetAll() dafür.
- CI: .gitea/workflows/ci.yml baut und testet bei jedem Push/PR.
  Dabei fehlende Release|Any CPU-Konfiguration für 6 Projekte in der
  .sln behoben (LehrerApp.Data.Tests wurde bei Release-Builds der
  Solution bislang stillschweigend übersprungen). TreatWarningsAsErrors
  jetzt aktiv.
- SettingsViewModel (1986 Zeilen) als partial class auf 20 Themen-
  dateien aufgeteilt, Verhalten unverändert.
- Backup: optionaler zweiter Sicherungsordner (USB-Stick/Netzlaufwerk,
  best-effort) und Integritätsprüfung nach jedem Backup
  (DatabaseEncryptionService.CanOpenAndRead).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 00:52:44 +02:00
adminandClaude Sonnet 5 dbd8777e64 Punkteeingabe: Enter und Pfeiltasten unterstützen beide Korrektur-Workflows
Enter läuft jetzt zeilenweise (Arbeit für Arbeit), Pfeil-Hoch/-Runter spaltenweise
mit Umbruch zur Nachbaraufgabe (Aufgabe für Aufgabe) - beides ohne Moduswechsel,
da sich beide Reihenfolgen ohnehin auf dieselbe Tabelle abbilden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:25:53 +02:00
admin 89a6376fb6 Improve desktop UX and accessibility 2026-08-29 20:56:55 +02:00
admin b7a105af73 Dashboard und Schnellnavigation fix 2026-08-29 19:35:05 +02:00
adminandClaude Sonnet 5 7d3d021d09 Stundenplan: Wochenend-Flakiness in Unterrichtsmodus-Test behoben
OpenWeekCell_LessonHeuteWaehrendUnterrichtszeit_OeffnetUnterrichtsmodus schlug
an Sa/So fehl, weil today.DayOfWeek dort keiner Kachel im Mo-Fr-Wochenraster
entspricht. TimetableViewModel bekommt dafür eine austauschbare Clock-Property
(Standard: DateTime.Now), sodass der Test "heute" unabhängig vom tatsächlichen
Wochentag simulieren kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:35:16 +02:00
adminandClaude Sonnet 5 7fbf035cbb Klausuren-Hauptseite: gruppenübergreifende Liste mit Prioritäts-Score statt Datum
Sidebar-Klausuren war bisher ein Placeholder. Neue Seite zeigt alle Klausuren des
Schuljahres sortiert nach abgeleiteter Dringlichkeit (Korrekturfortschritt statt
manuellem Status), mit Detailbereich, Parallelkurs-Umschalter und Notenspiegel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 01:27:18 +02:00
admin 9b04d7eb98 UX-Update 2.0, Neues Dashboard 2026-08-29 00:45:19 +02:00
adminandClaude Sonnet 5 229ef79e75 Stundenplan: Popup-Menü im Wochenraster statt Dropdown in der Tagesliste
Nutzer-Feedback: die Tagesliste-Buttons waren schon in Ordnung, das Problem
lag im Wochenraster darüber — ein Klick auf eine Stunden-Kachel führt dort
entweder in den Planungsviewer oder zur Einheitenplanung, ohne dass von
außen erkennbar wäre welches Ziel man bekommt. Tagesliste auf den
ursprünglichen Stand zurückgesetzt.

Neu: ein kleiner "⋮"-Button pro Kachel öffnet ein Popup-Menü mit vier
ausdrücklich benannten Zielen (Unterrichtsansicht/Planungsviewer/Sitzplan/
Planung). MenuFlyout statt ComboBox — dabei verstanden, dass ein
$parent[ItemsControl]-Vorfahrenpfad im Flyout nicht funktioniert, eine
normale {Binding} über die DataContext-Vererbung aber sehr wohl. Der
Direktklick springt jetzt außerdem "einheitlicher": bei einer Lesson
während der eigentlichen Unterrichtszeit direkt in den Unterrichtsmodus
statt in den Viewer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 23:21:49 +02:00
adminandClaude Sonnet 5 cd08bbbbc4 Stundenplan: Dropdown für Unterrichtsansicht/Sitzplan/Planung/Planungsviewer
Die zwei Buttons in der "Heute"-Tagesliste mit teils vom Lesson-Status
abhängiger Doppelbedeutung ("Verlaufsplan ansehen"/"Zur Lerngruppe") waren
unklar. Ersetzt durch ein Dropdown mit vier ausdrücklich benannten Zielen;
drei nutzen bestehende Commands, "Sitzplan" (Sitzpläne-Tab) ist neu und
bisher nur indirekt über den Unterrichtsmodus erreichbar gewesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:03:11 +02:00
adminandClaude Sonnet 5 442e3d5e3d Unterrichtsmodus: Schnellbewertung und Hausaufgabe-Kontrolle direkt im Fenster
Zwei neue Buttons ("Mitarbeit", "Anwesenheit/Hausaufgabe") öffnen die
bestehenden Schnellbewertungs-Dialoge direkt aus dem Unterrichtsmodus, ohne
zum Mitarbeit-Tab wechseln zu müssen — TeachingModeViewModel bekommt dafür
eine eigene ParticipationTabViewModel-Instanz, vorselektiert auf die zu
dieser Stunde gehörende Sitzung. Die linke Spalte zeigt jetzt zusätzlich die
Hausaufgabe der letzten Stunde mit Kontrolliert-Checkbox sowie die
Hausaufgabe dieser Stunde als editierbares Feld, statt nur lesbar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 16:20:40 +02:00
adminandClaude Sonnet 5 3f484ce3c8 Klassenlehrer-Tab: vier Feature-Ideen umgesetzt (ohne Elterngesprächs-PDF)
Offene Entschuldigungen mit Frist (neue Seitenspalten-Karte, sortiert nach
Tagen offen), aggregierte Klassenbuch-Kategorien (Chip-Reihe im
Klassenbuch-Tab), Wochentags-Häufung als dritte Mustererkennungsregel, und
eine Wiedervorlagen-Verknüpfung: ein "+"-Button legt direkt aus der Zeile
eine WorkTask "Eltern kontaktieren" an, und eine dritte Musterregel
verknüpft hohe Jahresfehlquote mit fallendem Mitarbeit-Trend.

ContextMenu auf den Zeilen-Buttons scheiterte an Avalonias
$parent-Vorfahren-Bindings (Popup hängt nicht im normalen visuellen Baum) —
stattdessen zwei Geschwister-Buttons pro Zeile. Elterngesprächs-Blatt als
PDF bewusst zurückgestellt, Konzept noch nicht geschärft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:57:09 +02:00
adminandClaude Sonnet 5 3f8dab4abd Klassenlehrer-Tab: Warnung/Gefahr-Farben per validate_palette.js ersetzt
Amber und Korallrot waren laut Nutzer-Feedback auf dem Bildschirm kaum
auseinanderzuhalten. Die dataviz-Skill-Validierung bestätigte das
quantitativ (ΔE 2.5 unter Deuteranopie, 11.4 selbst bei normaler
Farbwahrnehmung — beides unter der Zielschwelle). Neue Werte aus der
validierten Kategorial-/Statuspalette der Skill statt erneut freihändig
gewählter Farben; Tagesüberblick-Balken tauscht zusätzlich die
Segmentreihenfolge, weil Grün und das neue Dunkel-Gelb sonst direkt
aneinanderstießen. Auch ein Kopiereinfügen-Duplikat in TODO.md behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:33:54 +02:00
adminandClaude Sonnet 5 b3b86f0cf4 Klassenlehrer-Tab: restliche Quick-Wins (responsive Balken, Tagesüberblick-Chart, Tooltips, Status-Symbol)
Balkenbreiten hingen bisher an einer im ViewModel hart codierten Pixelkonstante;
Grid.ColumnDefinitions ließ sich dafür nicht binden (AVLN3000, kein Setter bei
kompilierten Bindings), deshalb neuer FractionWidthConverter (MultiBinding
gegen die gerenderte Breite einer Track-Border statt Grid-Sternspalten).
Tagesüberblick zeigt jetzt denselben gestapelten Balken statt vier
Einzelzeilen. AbsenceTooltip und TodayUnexcusedPercent waren berechnet aber
ungebunden, jetzt sichtbar. Status-Zeilen bekommen zusätzlich zur Farbe ein
vorangestelltes Symbol (Barrierefreiheit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:25:32 +02:00
adminandClaude Sonnet 5 9d375caf14 Klassenlehrer-Tab: Theme-Ressourcen statt Hex-Farben, gestapelter Trend, Fehlquote seit Schuljahresbeginn
Semantische Brushes (Light/Dark) lösen die fest codierten Dark-Hex-Werte ab, die
im hellen Theme unbenutzbar waren. Trend-Chart zeigt jetzt einen echten
gestapelten Balken über die letzten 7 Schultage statt drei unabhängig
skalierten über Kalendertage; neue Fehlquote je Schüler*in seit
Schuljahresbeginn ergänzt den Heute-Snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:47:13 +02:00
admin cf8de08501 Redesign Klassenlehrer Tab. 2026-08-26 22:50:40 +02:00
admin 265ffe9a74 Untis: Stundenplansync ab App Start 2026-08-26 22:24:10 +02:00
adminandClaude Sonnet 5 0c8bb58e1b fix: Klassenlehrer-Übersicht rief Schülerreport bislang live statt gecacht ab
Die Übersicht wirkte beim Öffnen spürbar zeitverzögert, obwohl Fehlzeiten
und Klassenbuch schon lokal gecacht waren (Nutzer-Feedback: "fühlt sich an
wie ein Live-Pull mit CSV-Parsing"). Grund: genau das passierte - der
Schülerreport (Namen fürs Roster) lief komplett am Cache vorbei bei jedem
Öffnen live gegen WebUntis.

Neues UntisStudentRosterCacheEntry/UntisStudentRosterCacheRepository
(gleiches "kein db.OnChange"-Prinzip wie die bestehenden Caches) plus
UntisReportCacheService.GetStudentRosterAsync - ohne heißes/kaltes Fenster,
da eine Klassenliste keine Historie hat, nur dieselbe Stundenschwelle
"gilt der letzte Abruf noch als frisch".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:18:53 +02:00
adminandClaude Sonnet 5 69cc16bbd2 fix: Namensabgleich und Tab-Wechsel im Klassenlehrer-Bereich, Fehltag-Hervorhebung
Zwei Bugs behoben:
- Gefilterte Listen blieben nach Klick auf eine Roster-Zeile leer: WebUntis
  liefert Schülernamen je nach Bericht in unterschiedlicher Reihenfolge
  (Schülerreport "Vorname Nachname", Fehlzeiten-/Klassenbuchberichte
  "Nachname Vorname"). Neuer reihenfolge-unabhängiger Namensvergleich
  (UntisNameMatching), betraf auch den Roster-Namensabgleich der Übersicht
  selbst (Ampel-Symbole/Badges waren ohne ExternKey vermutlich fast immer
  falsch).
- Nach dem ersten Tab-Wechsel (Klick auf Roster-Zeile) blieb ein zweiter
  Klick wirkungslos, sobald man zwischendurch manuell zur Übersicht
  zurückgeklickt hatte: TabbedPage.SelectedIndex war ohne Mode=TwoWay
  gebunden. Explizit ergänzt.

Zusätzlich: Fehlzeiten-Zeilen für einen kompletten Fehltag werden jetzt
dezent hervorgehoben (Nutzer-Feedback, bisher nicht erkennbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:27:14 +02:00
adminandClaude Sonnet 5 2b29dea824 WIP (unstable): lokaler Cache für Klassenlehrer-Fehlzeiten & Klassenbucheinträge
Nutzer-Feedback: die WebUntis-Berichtszeilen sind starr genug für ein eigenes
Datenmodell, Warnungen sollen sofort da sein statt bei jedem Öffnen neu
abgerufen zu werden - vor allem darf derselbe Bericht nicht mehrfach pro
Stunde abgerufen werden, nur weil die Ansicht mehrfach geöffnet wird (Sorge,
bei WebUntis aufzufallen).

Neue Modelle UntisAbsenceCacheEntry/UntisClassRegisterCacheEntry (1:1 zu den
bestehenden DTOs) + UntisCacheFetchState, bewusst nicht synchronisiert
(gleiches "kein db.OnChange"-Muster wie UntisSnapshotEntry/AnnualPlanEvent) -
jedes Gerät ruft WebUntis selbst ab, die Zeilenzahl wächst übers Schuljahr
gewollt an.

UntisReportCacheService: festes heißes Fenster der letzten 14 Tage, höchstens
stündlich automatisch aufgefrischt; alles Ältere gilt als endgültig und wird
dauerhaft aus dem Cache bedient. Die Entscheidungslogik (Plan) ist als reine,
ohne Repositories/HTTP testbare Funktion ausgelagert. Klassenlehrer-Ansichten
nutzen den Cache-Service statt WebUntisIntegrationService direkt; ein
zusätzlicher Button umgeht die Stundensperre bewusst für manuelle Abrufe.

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 23:10:01 +02:00
adminandClaude Sonnet 5 056e864edd fix: WebUntis-Schülerreport verkraftet fehlenden externKey
Klassenlehrer-Übersicht scheiterte komplett mit "Ungültige Zahl in Spalte
externKey", sobald ein Schüler der Klasse keinen gepflegten externen
Schlüssel hat (in echten Schuldaten vorkommend). externKey ist jetzt
optional statt Pflichtfeld (UntisStudent/UntisStudentDto.ExternKey -> int?);
betraf denselben Bericht wie der bestehende WebUntis-Klassenimport, der das
aber schon immer beim Weiterimport toleriert hatte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:47:04 +02:00
adminandClaude Sonnet 5 33c15ffe3e WIP (unstable): Klassenlehrer-Feature auf Einstellungen/eigenen Bereich umgestellt
Nutzer-Feedback: Klassenlehrer ist eine Rolle für die ganze Klasse, unabhängig vom
eigenen Unterricht - die Anbindung an ein LearningGroup.IsClassTeacher-Flag mit Tab
in der Gruppendetailansicht war deshalb der falsche Ort. Ersetzt durch:

- Klassenauswahl in den WebUntis-Einstellungen (WebUntisSettingsService.HomeroomClassName),
  über den bestehenden WebUntis-Klassenpicker.
- Eigener Sidebar-Bereich "Klassenlehrer" statt Gruppen-Tab.
- Zwei Ebenen: kompakte Roster-Übersicht mit Ampel-Symbolen (Fehlzeiten heute,
  Klassenbuch-Badge) und Details (die bisherigen Rohdaten-Listen, jetzt gefiltert
  auf einzelne Schüler*innen anspringbar).

Noch nicht mit echtem WebUntis-Zugang gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:30:34 +02:00
admin 5e16ebf1bf Update Sync 2026-08-25 21:30:00 +02:00
adminandClaude Sonnet 5 7245196689 WIP (unstable): Klassenlehrer-Tab (fremde Klassenbucheinträge + Fehlzeiten der Klasse)
Neuer Tab in der Gruppendetailansicht (LearningGroup.IsClassTeacher-Flag), zeigt
Klassenbucheinträge anderer Lehrkräfte sowie Fehlzeiten der ganzen Klasse über alle
Fächer, pro Schüler*in und Tag zusammengefasst. Beides rein lesend, kein Übernehmen
in lokale Daten. Fehlzeiten-Bericht (AbsencePerStudent) adressiert die Klasse über
"KL"+getKlassen-ID, automatisch aufgelöst - noch nicht mit echtem WebUntis-Zugang
gegengeprüft (siehe TODO.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 21:19:39 +02:00
adminandClaude Sonnet 5 f9f5f75aa8 Dokumentation: Eintrag von WebUntis-Klassenbuch-Abgleich ausschließen
Neues Feld Documentation.ExcludeFromWebUntisSync (Checkbox im Dialog) blendet
den Eintrag aus dem Zwischenablage-Vorschlag im Klassenbuch-Abgleich aus, für
Notizen, die nur für die eigene pädagogische Arbeit gedacht sind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:08:50 +02:00
admin 7fa8a93c82 Klassenbucheinträge abgleich und poll 2026-08-25 09:31:41 +02:00
admin 87a7badb44 Untis-API: Fehlzeiten abgleich 2026-08-25 00:21:53 +02:00
admin 17475a781f Untis-API Optimierung. 2026-08-24 22:33:30 +02:00
admin 1db16f8b69 Untis-API: Download Bug behoben 2026-08-24 22:06:42 +02:00
admin 34a9fdf73b Untis-API von Server zu Client 2026-08-24 22:00:26 +02:00
admin 0f10f754d0 Untis API Integration 2026-08-24 21:52:00 +02:00
admin 5841d96c5b Vorarbeit: WebUntis-API 2026-08-24 12:14:36 +02:00
adminandClaude Sonnet 5 a30aab0fa5 Fix: doppelte Mitarbeits-Sitzung durch Unterrichtsmodus-Instanz
EnsureTodaySession() prüfte nur den lokalen Instanz-Zustand, nicht das
Repository. Da der Unterrichtsmodus eine eigene, per DI frisch aufgelöste
SeatingPlanTabViewModel-Instanz nutzt (getrennt vom Sitzplan-Tab der
Kursübersicht), legte eine spätere Bewertung im normalen Tab eine zweite,
unverknüpfte Sitzung für denselben Tag an, statt die bereits über den
Unterrichtsmodus verknüpfte zu erkennen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:43:19 +02:00
adminandClaude Sonnet 5 07788dcfcb TODO: TickTick-Sync für Erinnerungen ergänzt
Recherche zur TickTick Open API (Auth per persönlichem Token, Push/Pull-
Endpunkte, Architektur analog zum bestehenden WebUntis-Abgleich) als
neuer Punkt 6.1.7 festgehalten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 01:12:47 +02:00
376 changed files with 36856 additions and 3522 deletions
+24
View File
@@ -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"
+21 -6
View File
@@ -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": [
+13
View File
@@ -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
View File
@@ -60,12 +60,16 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
orchestration, timer-driven), `SnapshotService`, `Crypto/SyncCrypto` (AES-256-GCM payload
encryption — desktop events are encrypted at rest and in transit; Companion/WebApp events are
plaintext, see `PlainSyncEvent` vs `SyncEvent`).
- **LehrerApp.WebUntis** — direkter, serverunabhängiger WebUntis-Client für den Desktop. Hält die
persönliche JSON-RPC-Sitzung lokal und parst den Schülerreport lokal; WebUntis-Zugangsdaten und
personenbezogene Antworten dürfen nicht über `LehrerApp.Api` geleitet werden.
- **LehrerApp.Api** — minimal ASP.NET Core server: JWT auth, an append-only `EventStore` plus
`SnapshotStore`/`ReadableSnapshotStore` per device, mapped in `Endpoints/Endpoints.cs`. Sync is
optional — Desktop only registers `SyncEngine`/`SnapshotService` in DI when a server URL is
configured (`AppBootstrapper.LoadServerUrl`).
- Each library has a matching `*.Tests` project (`LehrerApp.Tests` → Core, `LehrerApp.Data.Tests`
Data, `LehrerApp.Desktop.Tests` → Desktop, `LehrerApp.Sync.Tests` → Sync), all xUnit.
`LehrerApp.WebUntis.Tests` covers the direct WebUntis client and report parser.
### MVVM conventions (Desktop)
+1 -1
View File
@@ -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>
+6 -2
View File
@@ -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
View File
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Globalization;
using LehrerApp.Core.Models;
using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc;
+2 -1
View File
@@ -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);
+37
View File
@@ -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);
}
}
+6
View File
@@ -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;
}
+21
View File
@@ -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;
}
+41
View File
@@ -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,
};
}
}
+43 -1
View File
@@ -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; }
}
+79
View File
@@ -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; }
}
+24
View File
@@ -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; }
}
+49
View File
@@ -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;
}
+5 -1
View File
@@ -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
+34
View File
@@ -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);
}
+17 -1
View File
@@ -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);
}
}
+20
View File
@@ -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(
+156
View File
@@ -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.
+13
View File
@@ -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([]));
private LetterTemplateService ServiceWithTemplate(params string[] tags)
[Fact]
public async Task AdvancedContent_Anwesenheitskalender_WirdAlsDrawingGerendert()
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
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");
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));
}
// 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
{
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>");
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;
}
var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}"));
service.Import(source, "Elternbrief");
return service;
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);
}
}
+125 -3
View File
@@ -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());
}
}
+901
View File
@@ -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()
{
@@ -543,6 +672,50 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(session.Id, vm.SelectedSession?.Id);
}
[Fact]
public async Task AssessStudent_SitzungBereitsUeberAndereInstanzVerknuepft_LegtKeineZweiteAn()
{
// Regressionstest: Der Unterrichtsmodus läuft auf einer eigenen, per DI frisch aufgelösten
// SeatingPlanTabViewModel-Instanz (TimetableView.ShowTeachingMode), getrennt von der
// Instanz im Sitzplan-Tab der Kursübersicht — beide teilen sich nur das Repository. Wurde
// (wie hier simuliert) die Sitzung bereits über den Unterrichtsmodus mit der Stunde
// verknüpft, bevor im echten Sitzplan-Tab bewertet wird, darf letzterer keine zweite,
// unverknüpfte "Sitzplan"-Sitzung für denselben Tag anlegen.
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 memberships = new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]);
// Instanz A: Sitzplan-Tab der Kursübersicht, bereits initialisiert (kein SelectedSession).
var tabVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
tabVm.Initialize(groupId, isReadOnly: false);
tabVm.OnAssessStudent = _ => Task.CompletedTask;
// Instanz B: eigene, frische Instanz wie im Unterrichtsmodus — verknüpft die Sitzung mit
// der Stunde, ohne dass Instanz A davon erfährt.
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var teachingModeVm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, sessions, new FakeEntries(), new FakeAspects());
teachingModeVm.Initialize(groupId, isReadOnly: false);
teachingModeVm.SelectOrCreateSessionForLesson(lesson);
var linked = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, linked.LessonId);
await tabVm.AssessStudent(tabVm.Seats.Single());
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(linked.Id, session.Id);
Assert.Equal(lesson.Id, session.LessonId);
Assert.Equal(session.Id, tabVm.SelectedSession?.Id);
}
[Fact]
public void ShuffleSeats_ErzeugtNeuenPlanMitVertauschtenPlaetzenUndBehaeltDenUrsprungsplan()
{
@@ -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());
}
@@ -0,0 +1,88 @@
using System.Net;
using System.Text;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisIntegrationServiceTests
{
[Fact]
public async Task ConnectUndAbruf_GehenDirektAnWebUntisUndVerwendenDieselbeSession()
{
var handler = new QueueHandler(
Json("{\"result\":{\"sessionId\":\"desktop-session\"}}"),
Json("{\"result\":[{\"id\":1,\"name\":\"2026/27\",\"startDate\":20260801,\"endDate\":20270731}]}"),
Json("{\"result\":[{\"id\":42,\"name\":\"KUE\",\"longName\":\"Kürbis\",\"active\":true}]}"),
Json("{\"result\":{}}"));
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 teachers = await service.GetTeachersAsync();
Assert.Equal("KUE", Assert.Single(teachers).Name);
Assert.Equal(3, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.StartsWith("https://arche.webuntis.com/WebUntis/", request.Uri));
Assert.DoesNotContain(handler.Requests, request => request.Uri.Contains("/api/webuntis"));
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"),
};
private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(new(request.RequestUri?.ToString() ?? "",
request.Content is null ? "" : await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed record CapturedRequest(string Uri, string Body);
}
@@ -86,4 +86,39 @@ public sealed class WebUntisSettingsServiceTests
Assert.Equal(at, reloaded.LastSyncAt);
Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus);
}
[Fact]
public void ApiCredentials_SindVerschluesseltUndPersistieren()
{
var path = BuildTempPath();
var credentials = new WebUntisCredentials("schule", "mese.webuntis.com", "lehrkraft", "sehr geheim");
var service = new WebUntisSettingsService(path);
service.SetApiCredentials(credentials);
service.SetTeacherUntisId(4711);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
var reloaded = new WebUntisSettingsService(path);
Assert.DoesNotContain("sehr geheim", raw);
Assert.True(reloaded.ApiIsConfigured);
Assert.Equal(credentials, reloaded.GetApiCredentials());
Assert.Equal(4711, reloaded.TeacherUntisId);
}
[Fact]
public void ClearApiCredentials_LaesstIcalKonfigurationUnveraendert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetApiCredentials(new("schule", "", "user", "password"));
service.SetTeacherUntisId(12);
service.ClearApiCredentials();
Assert.False(service.ApiIsConfigured);
Assert.Null(service.GetApiCredentials());
Assert.Null(service.TeacherUntisId);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
}
@@ -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; }
}
+33
View File
@@ -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>
+187 -28
View File
@@ -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);
@@ -35,41 +42,64 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
// (und damit BuildServices, das sie öffnet) angefasst wird.
if (AppBootstrapper.IsDbEncrypted())
{
var promptVm = new DbPasswordPromptViewModel(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
promptVm.OnUnlocked = password =>
{
AppBootstrapper.DbPassword = password;
StartMainApp(desktop, showImmediately: true);
promptWindow.Close();
};
desktop.MainWindow = promptWindow;
}
else
{
StartMainApp(desktop, showImmediately: false);
}
// 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 void StartMainApp(
IClassicDesktopStyleApplicationLifetime desktop, bool showImmediately)
private static async void ContinueStartup(
IClassicDesktopStyleApplicationLifetime desktop, SplashWindow splash)
{
_serviceProvider = AppBootstrapper.BuildServices();
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
// (und damit BuildServices, das sie öffnet) angefasst wird.
if (AppBootstrapper.IsDbEncrypted())
{
var promptVm = new DbPasswordPromptViewModel(
new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath());
var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm };
promptVm.OnUnlocked = async password =>
{
AppBootstrapper.DbPassword = password;
var unlockedSplash = new SplashWindow();
desktop.MainWindow = unlockedSplash;
unlockedSplash.Show();
promptWindow.Close();
await StartMainAppAsync(desktop, unlockedSplash);
};
desktop.MainWindow = promptWindow;
promptWindow.Show();
splash.Close();
return;
}
await StartMainAppAsync(desktop, splash);
}
private static async Task StartMainAppAsync(
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
{
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
@@ -100,11 +165,14 @@ public class App : Application
private static void DisposeServices()
{
if (_serviceProvider is null) return;
var serviceProvider = Interlocked.Exchange(ref _serviceProvider, null);
if (serviceProvider is null) return;
try
{
_serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
StartupCancellation.Cancel();
_initialPolls.GetAwaiter().GetResult();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
{
@@ -112,8 +180,19 @@ public class App : Application
}
finally
{
_serviceProvider.Dispose();
_serviceProvider = null;
// Der Exit-Handler läuft synchron auf dem Avalonia-UI-Thread. Die asynchrone
// Entsorgung darf dort nicht mit GetResult() gestartet werden: Fortsetzungen aus
// WebUntis/HttpClient könnten sonst auf den blockierten UI-Kontext zurückwarten.
try
{
Task.Run(async () =>
await serviceProvider.DisposeAsync().ConfigureAwait(false))
.GetAwaiter().GetResult();
}
catch (Exception ex)
{
AppBootstrapper.Logger.Error("Dienste konnten beim Beenden nicht vollständig freigegeben werden.", ex);
}
}
}
@@ -130,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>();
@@ -150,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);
}
}
+62 -5
View File
@@ -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);
@@ -223,6 +270,9 @@ public static class AppBootstrapper
services.AddSingleton(syncSettings);
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.
@@ -284,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(
@@ -296,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>();
@@ -311,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);
}
}
@@ -9,6 +9,8 @@
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<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" />
@@ -19,6 +21,7 @@
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
<PackageReference Include="ModelContextProtocol.Core" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
+5
View File
@@ -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);
+138 -6
View File
@@ -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());
}
+114
View File
@@ -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);

Some files were not shown because too many files have changed in this diff Show More