Author SHA1 Message Date
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
admin 788ae6ad32 Wetterdienst: Hover Bugfix 2026-08-23 21:51:26 +02:00
admin 83a430f8ee Wetterdienst: Erweitert 2026-08-23 21:45:25 +02:00
admin e76884a58e Wetterdienst: Verhersage verlängert, Anzeige im Stundenplan. 2026-08-23 21:31:15 +02:00
admin 1b32166ce0 Wetterdienst 2026-08-23 20:51:19 +02:00
admin cc973418f3 Wetterdienst 2026-08-23 20:51:06 +02:00
admin dd5ecb0334 Jahresplanimport und Ablgeich von Untis 2026-08-23 19:23:05 +02:00
adminandClaude Sonnet 5 4a59bff9de Merge branch 'unstable': WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:49 +02:00
adminandClaude Sonnet 5 4b9bcfe85d fix: WebUntis-Vertretungen für kombinierte Gruppen und Karteileichen
- ClassToken-Vergleich normalisiert jetzt symmetrisch (beide Seiten) und
  ExtractClassTokens akzeptiert ";" und "," als Trennzeichen zwischen
  Klassen, da nie an einem echten kombinierten Termin verifiziert wurde,
  welches WebUntis tatsächlich verwendet.
- Abweichende Vertretungen tragen jetzt den genauen Vergleichsgrund
  (Fach/Klasse, roh vs. erwartet) in ihrer Beschreibung.
- Automatisch erzeugte Vertretungen, die bei einem späteren Poll nicht
  mehr abweichen, werden jetzt aktiv wieder entfernt statt als
  Karteileichen stehen zu bleiben (UntisDiffResult.SubstitutionExternalIdsToDelete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:43:36 +02:00
adminandClaude Sonnet 5 4eb4d0a946 WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:30:15 +02:00
adminandClaude Sonnet 5 e65a729f97 feat: Pädagogische Klassen-Aufgaben, Prioritäten/Checklisten, Dashboard-Schnellzugriffe
Neue Aufgabenart über bestehende Felder (WorkTask.Kind=Reminder + GroupId) statt Vererbung,
mit Priorität (TaskPriority) und optionaler Abhak-Liste (ChecklistItems, wahlweise Kurs-
Roster oder Freitext). Kurs-Dashboard zeigt jetzt eine "Anstehende Aufgaben"-Karte samt
direktem Anlege-Button; derselbe Anlege-Einstieg wurde auch ins Hauptdashboard und (als
Schnellüberblick samt fehlender Übersicht/Mitarbeit-Buttons) in die Gruppenliste gezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:24:38 +02:00
admin e7c3fdd4ec Bugfix: Label path 2026-08-20 23:59:30 +02:00
admin 59bd8abb45 feat: sync guard - Blockieren alter Clients beim sync 2026-08-20 23:54:04 +02:00
adminandClaude Sonnet 5 7b883555bf feat: Chemikalien-Recherche über lokale RiSU-Stoffliste (kein KI-Aufruf mehr)
Ersetzt den ursprünglichen KI-Websuche-Ansatz (GESTIS scheiterte sowohl über
Websuche als auch über direkten API-Zugriff an fehlenden schulspezifischen
Tätigkeitsbeschränkungen) durch einen reinen lokalen Datenbank-Lookup gegen die
offizielle RiSU-Stoffliste (1764 Stoffe inkl. Tätigkeitsbeschränkungs-Codes,
Legende zur Abfragezeit übersetzt statt in die Datenbank gebacken). Keine
KI-Kosten, keine Drittanbieter-Abhängigkeit mehr für diese Funktion.

HazardSubstance.AgeRestriction zu ActivityRestriction umbenannt, da die
Tätigkeitsbeschränkung mehr abdeckt als reine Altersgrenzen. Details und
verworfene Zwischenstände in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:43:56 +02:00
adminandClaude Sonnet 5 038337997f feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent
Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:13:14 +02:00
adminandClaude Sonnet 5 331033db5c feat: Dunkelmodus, Fenstergröße merken, Papierkorb für Löschvorgänge (12.4, 14.3, 14.6)
Dunkelmodus über neuen Einstellungen-Tab "Darstellung" (Systemvorgabe/Hell/Dunkel),
Fenstergröße/Maximiert-Status wird über Sitzungen hinweg gemerkt (bewusst ohne
Fensterposition), und ein generischer Snapshot-basierter Papierkorb (30 Tage) für
Sitzpläne, Noten, Notenschlüssel-Vorlagen, Aufgaben und Zeiteinträge. Details und
bewusste Scope-Entscheidungen (Spaltenbreiten zurückgestellt, Farb-Audit für
Dunkelmodus offen, welche Entitäten der Papierkorb abdeckt) in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:26:10 +02:00
adminandClaude Sonnet 5 d9d007cd4b feat: Unterrichtsmodus - konsolidierte Ansicht für die laufende Stunde
Verlaufsplan und Sitzplan bisher über mehrere Tabs verteilt, obwohl im Unterricht selbst beides
gleichzeitig gebraucht wird. Neuer Button "▶ Unterricht" in der "Heute"-Tagesliste des
Stundenplans (nur bei vorhandener Lesson) öffnet ein maximiertes Fenster: links der
schreibgeschützte Verlaufsplan als kompakte Kartenliste, rechts der volle, unverändert
wiederverwendete SeatingPlanTabView (Drag&Drop, Schnellbewertung, Situations-Tags, PDF-Export).
TeachingModeViewModel baut auf dem bestehenden LessonViewerViewModel auf, dadurch kommen "Zur
Mitarbeit"/"Zu den Noten" ohne Duplizierung mit.

Neue SeatingPlanTabViewModel.SelectOrCreateSessionForLesson verknüpft die Mitarbeitssitzung
automatisch mit der konkreten Stunde - anders als die bestehende EnsureTodaySession darf das hier
sofort beim Start passieren, da durch den expliziten Klick für genau diese Stunde eindeutig
feststeht, worum es geht (keine Geistersitzungs-Gefahr wie beim bloßen Tab-Öffnen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:53:16 +02:00
adminandClaude Sonnet 5 7365947edc feat: PDF-Druck für Sitzplan, Notenliste, Klausur-Notenspiegel, Kompetenzbericht, Dokumentation
QuestPDF als PDF-Bibliothek eingeführt (11.3), bewusst nur in LehrerApp.Desktop referenziert -
zieht SkiaSharp-Native-Binaries mit, die im Server-Docker-Image nichts verloren haben. Neuer
PdfExportService mit gemeinsamem Kopf-/Fußzeilen-Layout (Titel, Stand-Datum, Seitenzahlen) und
schlanken Druck-DTOs statt ViewModels, damit die Erzeugung ohne Avalonia-Bezug testbar bleibt.
Speicherdialog läuft über den bestehenden ExportService (neues PDF-Format).

Fünf Druckvorlagen (11.4) als "Als PDF"-Button direkt in der jeweiligen Ansicht:
- Sitzplan: Raster mit Tafel-Banner, Tischabständen als echte Lücken, ausgeblendeten Plätzen als
  leere Rasterposition.
- Notenliste: druckt exakt die aktuell angezeigte Matrix (Zeitraum/Darstellung/Sortierung).
- Klausur-Notenspiegel: Kennzahlen, Notenverteilung und Aufgabenanalyse mit Balkendiagrammen,
  auffällig schwache Aufgaben rot markiert.
- Kompetenzbericht: Analyse-Tabelle für den ausgewählten Schüler (erledigt zugleich 8.3.3).
- Schülerdokumentation: alle Einträge chronologisch, vertrauliche/Entwurfs-Einträge markiert.

Layout an gerenderten Muster-PDFs visuell geprüft, nicht nur per Unit-Test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:27:05 +02:00
adminandClaude Sonnet 5 4cce84c7dd feat: Warnung + selbstverwalteter Wiederherstellungscode für verlorenen Sync-Schlüssel
Der Sync-Schlüssel verlässt nie den Server im Klartext - verliert das einzige Gerät sync.key,
erzeugte AppBootstrapper bisher stillschweigend einen neuen, unabhängigen Schlüssel, ohne dass
der Nutzer etwas davon merkt. Bereits synchronisierte Server-Daten werden dadurch dauerhaft
unlesbar.

AppBootstrapper erkennt jetzt, ob ein fehlender Schlüssel bei einem bereits eingeloggten Gerät
auftritt (SyncKeyStatus) - SettingsViewModel zeigt dafür ein Warnbanner im Synchronisation-Tab mit
der Empfehlung, im Zweifel über ein noch intaktes zweites Gerät neu zu koppeln.

Zusätzlich ein komplett offline laufender, selbstverwalteter Wiederherstellungscode (kein neuer
dauerhafter Server-Speicher für ein sicherheitsrelevantes Geheimnis): SyncCrypto bekommt einen
neuen, vom Pairing-Code kryptografisch unabhängigen Recovery-Code (120 Bit, Base32), mit dem der
Sync-Schlüssel lokal verschlüsselt und als Datei durch den Nutzer selbst gesichert werden kann.
Neuer SyncKeyRecoveryService kapselt Erzeugen/Einlösen; Redeem schreibt den wiederhergestellten
Schlüssel und setzt vorsorglich Pull-Cursor/Versionsverfolgung zurück.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:30:55 +02:00
adminandClaude Sonnet 5 c5a1157a7d feat: unbenutzte Sitzplätze ausblenden können
Manche Räume sind unregelmäßig bestückt (z.B. eine Klasse mit 3 Reihen, in zwei davon fehlt ein
Einzeltisch) - das feste Rows x Columns-Raster kennt aber keine per-Reihe abweichende Spaltenzahl.

Neues Feld SeatingPlan.HiddenSeats für Plätze ohne physischen Tisch. Ein ausgeblendeter Platz
bleibt Teil des Rasters (für die Spalten-/Reihenausrichtung von SeatingPlanPanel), wird im
Ansichtsmodus aber nicht gerendert; im Bearbeitungsmodus bleibt er sichtbar (abgeblendet) mit
einem Umschalt-Button direkt auf dem Platz. Nur leere Plätze lassen sich ausblenden; Zuweisen/
Verschieben eines Schülers auf einen ausgeblendeten Platz wird sowohl im ViewModel als auch im
Drag&Drop-Ziel-Check verhindert. SeatingPlanRepository.Save filtert HiddenSeats zusätzlich auf
das gültige Raster und blendet einen Platz automatisch wieder ein, falls er trotzdem belegt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 12:06:26 +02:00
adminandClaude Sonnet 5 391628ae36 Vernetzung Stundenplan ↔ Unterrichtsplanung: Terminvorschlag, Lesson-Viewer-Sprung, Verzweigung (4.5.1–4.5.3)
4.5.1: Terminvorschlag für neue Stunden schlägt jetzt auch die Stundennummer
vor (bei Doppelstunden die erste Periode), nicht nur den Wochentag —
zieht über den bestehenden LessonNumber-Changed-Hook automatisch auch den
Stundenbeginn nach.

4.5.2 (teilweise): Klick auf eine Stunde im Stundenplan ("Heute"-Liste und
read-only Wochenraster) springt bei bereits vorhandener Lesson direkt in
den Verlaufsplan-Viewer statt nur grob zum Planung-Tab. Das Anlegen einer
neuen Lesson direkt aus dem Stundenplan bleibt bewusst offen (ungeklärte
Unit-Zuordnung, siehe TODO.md).

4.5.3: Der Lesson-Viewer hat jetzt Buttons "Zur Mitarbeit"/"Zu den Noten"
zum Weiterverzweigen in die Lerngruppe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:39:27 +02:00
adminandClaude Sonnet 5 52a1d45539 Kurs-Übersicht: fehlende Hausaufgaben; Erinnerungen als eigene Aufgabenart; Kalender-/Sitzplan-Bugfixes
Kurs-Übersicht: neue Karte "Fehlende Hausaufgaben" listet Schüler mit nicht
gemachter Hausaufgabe aus der letzten Mitarbeitssitzung.

Aufgabenverwaltung: WorkTask.Kind unterscheidet jetzt Arbeitsaufträge von
reinen Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse
machen") - eigener Button "🔔 Erinnerung", eigenes Icon in Liste/Dashboard,
läuft nicht in die Zeitauswertung ein.

Drei Bugfixes aus Nutzer-Feedback:
- Dashboard-Kalender zeigte eine Stunde doppelt, wenn sie über "Sitzung
  erzeugen" mit einer Mitarbeitssitzung verknüpft war.
- Der Sitzplan-Tab legte beim bloßen Öffnen einer Gruppe (nicht erst bei
  echter Nutzung) eine leere "Sitzplan"-Sitzung für heute an.
- Der "Meine Klasse"-Ring im Kalender erschien erst, sobald für den Tag
  eine Lesson existierte, statt schon laut Stundenplan.

Außerdem: Drawer-Icons pinnen jetzt explizit auf die farbige Emoji-Schrift
(Windows kann sonst je nach Font-Fallback auf eine einfarbige Variante
ausweichen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 23:17:18 +02:00
adminandClaude Sonnet 5 4d2a89b14b Kurs-Übersicht gefüllt: nächste Termine, Mitarbeitshinweis, offene Kontrollen/Dokumentationen, auffällige Fehlzeiten
Der bislang leere "Übersicht"-Tab je Kurs zeigt jetzt einen kompakten
"Was steht an"-Überblick aus bereits vorhandenen Daten mit Klick-Durchsprung
in den jeweiligen Tab: nächste Stunde/Klausur, Hinweis auf die letzte
Mitarbeitssitzung, offene Hausaufgaben-Kontrolle, offene Entschuldigungen,
offene Dokumentations-Entwürfe und auffällige Fehlzeiten.

Die Fehlzeiten-Karte ist bewusst kein 1:1-Abbild der Dashboard-Warnung: erst
ab einer Mindeststichprobe von 8 erfassten Terminen im Schuljahr gemeldet,
damit ein einzelner Fehltag zu Schuljahresbeginn nicht sofort als auffällig
gilt. Das Hauptdashboard hat dasselbe Problem noch, wird dort separat
nachgezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:39:31 +02:00
adminandClaude Sonnet 5 f73bd6c9ec Sitzungen (3.3): aus Stunde erzeugen, löschen, Kalenderüberblick; Doppelstunden-Bugfix Serienerzeugung
Kapitel 3.3: neuer Button erzeugt eine Mitarbeitssitzung aus einer geplanten
Stunde (Datum/Thema, dupl.-sicher über Lesson.LessonId), Sitzungen lassen
sich jetzt mit Rückfrage bei vorhandenen Bewertungen löschen, und der
Dashboard-Kalender zeigt Sitzungen als dritten Termintyp neben Stunden/
Klausuren. Das Bearbeiten von Sitzungen war bereits vorhanden (undokumentiert
aus früherer Ad-hoc-Arbeit) und wird hier nur nachgezogen.

Bugfix: "Serie erzeugen" legte für Doppelstunden zwei Lessons mit gleichem
Datum an statt einer, da jede Stundenplan-Periode einzeln behandelt wurde.
Folgeperioden werden jetzt der Lesson der ersten Periode zugerechnet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:17:46 +02:00
admin 0faa3f54de Upgrade Sitzplan - Jetzt mit Bewertungsfeature 2026-08-19 16:46:08 +02:00
admin f514d1d58c Merge branch 'main' of https://git.science-teaching.de/admin/LehrerApp 2026-08-19 14:29:06 +02:00
admin 28beffe3b0 app icon win 2026-08-19 14:28:59 +02:00
349 changed files with 36847 additions and 2747 deletions
+3
View File
@@ -4,3 +4,6 @@
# Mindestens 32 zufällige Zeichen z.B. generiert mit:
# openssl rand -base64 32
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
# Identifiziert die Installation gegenüber Nominatim/DWD. Bei eigener Domain bitte anpassen.
GEOCODING_USER_AGENT=LehrerApp-Server/1.0 (+https://example.org)
+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"
+15 -1
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",
+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>
+5
View File
@@ -17,6 +17,11 @@
<!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- 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" />
+2 -1
View File
@@ -38,7 +38,8 @@ public sealed class AttachmentStoreTests
await store.StoreAsync("user-1", "shared-id", new MemoryStream([1]));
Assert.Null(store.OpenRead("user-2", "shared-id"));
Assert.NotNull(store.OpenRead("user-1", "shared-id"));
using var user1Attachment = store.OpenRead("user-1", "shared-id");
Assert.NotNull(user1Attachment);
}
[Fact]
@@ -0,0 +1,118 @@
using System.IO.Compression;
using System.Text;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class DwdWeatherServiceTests
{
[Fact]
public void ParseStationCatalog_LiestFestbreitenformatUndFindetNaechsteStation()
{
const string catalog = """
ID ICAO NAME LAT LON ELEV
----- ---- -------------------- ----- ------- -----
10641 EDDF OFFENBACH-WETTERPARK 50.08 8.78 119
10382 EDDT BERLIN-TEGEL 52.56 13.31 37
""";
var stations = DwdWeatherService.ParseStationCatalog(catalog);
var nearest = DwdWeatherService.FindNearestStation(stations, 50.1, 8.7);
Assert.Equal(2, stations.Count);
Assert.Equal("10641", nearest!.Id);
Assert.Equal("OFFENBACH-WETTERPARK", nearest.Name);
}
[Fact]
public void ParseMosmix_KonvertiertEinheitenUndBehaeltFehlwerteAlsNull()
{
var now = DateTime.UtcNow.AddHours(1).ToString("yyyy-MM-dd'T'HH:00:00.000'Z'");
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:IssueTime>2026-08-23T15:00:00Z</dwd:IssueTime>
<dwd:ForecastTimeSteps><dwd:TimeStep>{{now}}</dwd:TimeStep></dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>293.15</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FF"><dwd:value>5</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="FX1"><dwd:value>-</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="RR1c"><dwd:value>1.25</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="R101"><dwd:value>67.6</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="SunD1"><dwd:value>1800</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="Neff"><dwd:value>75</dwd:value></dwd:Forecast>
<dwd:Forecast dwd:elementName="ww"><dwd:value>61</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
var hour = Assert.Single(parsed.Hours);
Assert.Equal(20, hour.TemperatureC);
Assert.Equal(18, hour.WindSpeedKmh);
Assert.Null(hour.WindGustKmh);
Assert.Equal(1.3, hour.PrecipitationMm);
Assert.Equal(68, hour.PrecipitationProbabilityPercent);
Assert.Equal(30, hour.SunshineDurationMinutes);
Assert.Equal(61, hour.WeatherCode);
}
[Fact]
public void ParseMosmix_LiefertMehrAlsDreiTageFuerFolgewoche()
{
var start = DateTime.UtcNow.AddHours(1);
var timeSteps = string.Join("", Enumerable.Range(0, 100)
.Select(i => $"<dwd:TimeStep>{start.AddHours(i):yyyy-MM-dd'T'HH:mm:ss'Z'}</dwd:TimeStep>"));
var values = string.Join(' ', Enumerable.Repeat("293.15", 100));
var kml = $$"""
<?xml version="1.0" encoding="UTF-8"?>
<kml:kml xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:dwd="https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd">
<kml:Document><kml:ExtendedData><dwd:ProductDefinition>
<dwd:ForecastTimeSteps>{{timeSteps}}</dwd:ForecastTimeSteps>
</dwd:ProductDefinition></kml:ExtendedData><kml:Placemark><kml:ExtendedData>
<dwd:Forecast dwd:elementName="TTT"><dwd:value>{{values}}</dwd:value></dwd:Forecast>
</kml:ExtendedData></kml:Placemark></kml:Document>
</kml:kml>
""";
var parsed = DwdWeatherService.ParseMosmix(Zip("forecast.kml", kml));
Assert.Equal(100, parsed.Hours.Count);
}
[Fact]
public void ParseCapArchive_OrdnetWarnpolygonGeografischZu()
{
var xml = """
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>warnung-1</identifier><status>Actual</status>
<info><language>de-DE</language><event>GEWITTER</event><severity>Severe</severity>
<onset>2026-08-23T18:00:00Z</onset><expires>2099-08-23T20:00:00Z</expires>
<headline>Amtliche Warnung</headline><description>Test</description>
<area><polygon>50.0,8.0 50.0,9.0 51.0,9.0 51.0,8.0 50.0,8.0</polygon></area>
</info>
</alert>
""";
var warning = Assert.Single(DwdWeatherService.ParseCapArchive(
Zip("warning.xml", xml), DateTime.UtcNow));
Assert.True(DwdWeatherService.PointInPolygon(warning.Polygons[0], 50.5, 8.5));
Assert.False(DwdWeatherService.PointInPolygon(warning.Polygons[0], 52.0, 8.5));
Assert.Equal("GEWITTER", warning.Warning.Event);
}
private static byte[] Zip(string fileName, string content)
{
using var memory = new MemoryStream();
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(fileName);
using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false));
writer.Write(content);
}
return memory.ToArray();
}
}
@@ -0,0 +1,46 @@
using System.Net;
using System.Text;
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class NominatimGeocoderTests
{
[Fact]
public async Task GeocodeAsync_NutztStrukturierteDeutschlandSucheUndLiestDisplayName()
{
Uri? requestedUri = null;
var handler = new RecordingHandler(request =>
{
requestedUri = request.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""[{"lat":"50.105","lon":"8.761","display_name":"Testschule, Offenbach, Deutschland"}]""",
Encoding.UTF8, "application/json"),
};
});
var http = new HttpClient(handler) { BaseAddress = new Uri("https://nominatim.openstreetmap.org/") };
var geocoder = new NominatimGeocoder(http);
var result = await geocoder.GeocodeAsync(new SchoolLocationRequest
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "63000", City = "Offenbach",
}, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(50.105, result!.Latitude);
Assert.Equal("Testschule, Offenbach, Deutschland", result.DisplayName);
Assert.Contains("countrycodes=de", requestedUri!.Query);
Assert.Contains("street=Schulweg%201", requestedUri.Query);
Assert.Contains("postalcode=63000", requestedUri.Query);
}
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> response)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(response(request));
}
}
@@ -0,0 +1,49 @@
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SchoolWeatherStoreTests
{
[Fact]
public void SaveLocation_TrenntNutzerUndErsetztVorhandenenStandort()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveLocation("zwei", Location("Berlin", 52.5, 13.4));
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Equal("Bonn", temp.Store.GetLocation("eins")!.City);
Assert.Equal("Berlin", temp.Store.GetLocation("zwei")!.City);
}
[Fact]
public void SaveLocation_AenderungEntferntWettercacheDesAltenOrts()
{
using var temp = new TempStore();
temp.Store.SaveLocation("eins", Location("Köln", 50.9, 6.9));
temp.Store.SaveWeather("eins", new WeatherSnapshot { StationId = "alt", RetrievedAt = DateTime.UtcNow });
temp.Store.SaveLocation("eins", Location("Bonn", 50.7, 7.1));
Assert.Null(temp.Store.GetWeather("eins"));
}
private static SchoolLocationProfile Location(string city, double lat, double lon) => new()
{
SchoolName = "Testschule", Street = "Schulweg 1", PostalCode = "12345", City = city,
Latitude = lat, Longitude = lon, ResolvedAddress = city, UpdatedAt = DateTime.UtcNow,
};
private sealed class TempStore : IDisposable
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"lehrerapp-weather-tests-{Guid.NewGuid():N}");
public SchoolWeatherStore Store { get; }
public TempStore() { Directory.CreateDirectory(_path); Store = new(_path); }
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true);
}
}
}
@@ -0,0 +1,81 @@
using LehrerApp.Sync;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SyncProtocolVersionMiddlewareTests
{
[Theory]
[InlineData("/api/sync/push")]
[InlineData("/api/sync/plain/push")]
[InlineData("/api/sync/attachments/datei")]
[InlineData("/api/snapshot/upload")]
public async Task FehlendeVersion_BlockiertAlleSyncKanaeleVorDemEndpoint(string path)
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context(path);
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
Assert.Equal(SyncProtocol.CurrentVersion,
context.Response.Headers[SyncProtocol.VersionHeaderName].ToString());
}
[Fact]
public async Task AbweichendeVersion_BlockiertRequest()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = "0";
await middleware.InvokeAsync(context);
Assert.False(endpointReached);
Assert.Equal(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task AktuelleVersion_LaesstRequestZumEndpointDurch()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/sync/push");
context.Request.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
Assert.NotEqual(StatusCodes.Status426UpgradeRequired, context.Response.StatusCode);
}
[Fact]
public async Task NichtSyncEndpoint_BrauchtKeineVersion()
{
var endpointReached = false;
var middleware = Middleware(() => endpointReached = true);
var context = Context("/api/auth/login");
await middleware.InvokeAsync(context);
Assert.True(endpointReached);
}
private static SyncProtocolVersionMiddleware Middleware(Action reached) => new(_ =>
{
reached();
return Task.CompletedTask;
});
private static DefaultHttpContext Context(string path)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Response.Body = new MemoryStream();
return context;
}
}
+272
View File
@@ -0,0 +1,272 @@
using System.Globalization;
using System.IO.Compression;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
/// <summary>Lädt DWD-MOSMIX-Punktvorhersagen und CAP-Warnungen. Alle externen Antworten werden
/// pro Serverinstanz gecacht; pro Benutzer wird zusätzlich der letzte erfolgreiche Snapshot im
/// SchoolWeatherStore persistiert.</summary>
public sealed class DwdWeatherService(HttpClient http)
{
private const string StationCatalogUrl =
"https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.cfg?view=nasPublication";
private const string ForecastUrl =
"https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/{0}/kml/MOSMIX_L_LATEST_{0}.kmz";
private const string WarningsUrl =
"https://opendata.dwd.de/weather/alerts/cap/COMMUNEUNION_DWD_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMDWD_COMMUNEUNION_DE.zip";
private readonly SemaphoreSlim _gate = new(1, 1);
private List<MosmixStation>? _stations;
private DateTime _stationsLoadedAt;
private readonly Dictionary<string, CachedForecast> _forecastCache = [];
private CachedWarnings? _warningsCache;
public async Task<WeatherSnapshot> GetAsync(SchoolLocationProfile location,
CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var stations = await GetStationsAsync(cancellationToken);
var station = FindNearestStation(stations, location.Latitude, location.Longitude)
?? throw new InvalidOperationException("Der DWD-Stationskatalog enthält keine Stationen.");
var forecast = await GetForecastAsync(station, cancellationToken);
var warnings = await GetWarningsAsync(cancellationToken);
return new WeatherSnapshot
{
StationId = station.Id,
StationName = station.Name,
StationDistanceKm = Math.Round(DistanceKm(
location.Latitude, location.Longitude, station.Latitude, station.Longitude), 1),
ForecastIssuedAt = forecast.IssuedAt,
RetrievedAt = DateTime.UtcNow,
Forecast = forecast.Hours,
Warnings = warnings.Where(w => Contains(w.Polygons, location.Latitude, location.Longitude))
.Select(w => w.Warning)
.OrderByDescending(w => SeverityRank(w.Severity))
.ThenBy(w => w.Onset)
.ToList(),
};
}
finally { _gate.Release(); }
}
private async Task<List<MosmixStation>> GetStationsAsync(CancellationToken cancellationToken)
{
if (_stations is not null && DateTime.UtcNow - _stationsLoadedAt < TimeSpan.FromDays(7))
return _stations;
var text = await http.GetStringAsync(StationCatalogUrl, cancellationToken);
_stations = ParseStationCatalog(text);
_stationsLoadedAt = DateTime.UtcNow;
return _stations;
}
private async Task<ParsedForecast> GetForecastAsync(MosmixStation station,
CancellationToken cancellationToken)
{
if (_forecastCache.TryGetValue(station.Id, out var cached) &&
DateTime.UtcNow - cached.LoadedAt < TimeSpan.FromMinutes(45))
return cached.Forecast;
var bytes = await http.GetByteArrayAsync(string.Format(CultureInfo.InvariantCulture,
ForecastUrl, station.Id), cancellationToken);
var parsed = ParseMosmix(bytes);
_forecastCache[station.Id] = new(DateTime.UtcNow, parsed);
return parsed;
}
private async Task<List<ParsedWarning>> GetWarningsAsync(CancellationToken cancellationToken)
{
if (_warningsCache is not null &&
DateTime.UtcNow - _warningsCache.LoadedAt < TimeSpan.FromMinutes(5))
return _warningsCache.Warnings;
var bytes = await http.GetByteArrayAsync(WarningsUrl, cancellationToken);
var warnings = ParseCapArchive(bytes, DateTime.UtcNow);
_warningsCache = new(DateTime.UtcNow, warnings);
return warnings;
}
public static List<MosmixStation> ParseStationCatalog(string text)
{
var regex = new Regex(@"^(\S+)\s+\S+\s+(.{20})\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+", RegexOptions.Compiled);
var result = new List<MosmixStation>();
foreach (var line in text.Split('\n'))
{
var match = regex.Match(line.TrimEnd('\r'));
if (!match.Success ||
!double.TryParse(match.Groups[3].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) ||
!double.TryParse(match.Groups[4].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var lon))
continue;
result.Add(new(match.Groups[1].Value, match.Groups[2].Value.Trim(), lat, lon));
}
return result;
}
public static MosmixStation? FindNearestStation(IEnumerable<MosmixStation> stations,
double latitude, double longitude) => stations.MinBy(s =>
DistanceKm(latitude, longitude, s.Latitude, s.Longitude));
public static ParsedForecast ParseMosmix(byte[] kmz)
{
using var memory = new MemoryStream(kmz);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
var entry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".kml", StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException("Die MOSMIX-Datei enthält kein KML-Dokument.");
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace dwd = "https://opendata.dwd.de/weather/lib/pointforecast_dwd_extension_V1_0.xsd";
var times = document.Descendants(dwd + "TimeStep")
.Select(x => ParseDate(x.Value))
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
if (times.Count == 0) throw new InvalidDataException("MOSMIX enthält keine Zeitschritte.");
var values = document.Descendants(dwd + "Forecast")
.Where(x => x.Attribute(dwd + "elementName") is not null)
.ToDictionary(
x => x.Attribute(dwd + "elementName")!.Value,
x => ParseValues(x.Element(dwd + "value")?.Value ?? ""),
StringComparer.Ordinal);
double? Value(string key, int i) => values.TryGetValue(key, out var series) && i < series.Count
? series[i] : null;
var hours = new List<WeatherForecastHour>();
var earliest = DateTime.UtcNow.AddHours(-1);
// MOSMIX_L reicht bis +240 h. Die vollständige Spanne ist für die Unterrichtsplanung
// sinnvoll, weil das Wochenraster auch in die Folgewoche geblättert werden kann.
for (var i = 0; i < times.Count && hours.Count < 240; i++)
{
if (times[i] < earliest) continue;
hours.Add(new WeatherForecastHour
{
ValidAt = times[i],
TemperatureC = Round(Value("TTT", i) - 273.15, 1),
WindSpeedKmh = Round(Value("FF", i) * 3.6, 1),
WindGustKmh = Round(Value("FX1", i) * 3.6, 1),
PrecipitationMm = Round(Value("RR1c", i), 1),
// Beide Zusatzwerte stammen aus derselben MOSMIX-L-Datei. R101 bezeichnet die
// Wahrscheinlichkeit für >0,1 mm in der letzten Stunde, SunD1 liegt in Sekunden vor.
PrecipitationProbabilityPercent = Round(Value("R101", i), 0),
SunshineDurationMinutes = Round(Value("SunD1", i) / 60, 0),
CloudCoverPercent = Round(Value("Neff", i), 0),
WeatherCode = Value("ww", i) is { } code ? (int)Math.Round(code) : null,
});
}
return new(ParseDate(document.Descendants(dwd + "IssueTime").FirstOrDefault()?.Value), hours);
}
public static List<ParsedWarning> ParseCapArchive(byte[] zip, DateTime nowUtc)
{
var result = new List<ParsedWarning>();
using var memory = new MemoryStream(zip);
using var archive = new ZipArchive(memory, ZipArchiveMode.Read);
foreach (var entry in archive.Entries.Where(x => x.Name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)))
{
using var stream = entry.Open();
var document = XDocument.Load(stream);
XNamespace cap = "urn:oasis:names:tc:emergency:cap:1.2";
var root = document.Root;
if (root is null || root.Element(cap + "status")?.Value != "Actual") continue;
var info = root.Elements(cap + "info").FirstOrDefault(x =>
x.Element(cap + "language")?.Value.StartsWith("de", StringComparison.OrdinalIgnoreCase) == true)
?? root.Elements(cap + "info").FirstOrDefault();
if (info is null) continue;
var expires = ParseDate(info.Element(cap + "expires")?.Value);
if (expires is { } until && until < nowUtc) continue;
var polygons = info.Elements(cap + "area")
.SelectMany(x => x.Elements(cap + "polygon"))
.Select(x => ParsePolygon(x.Value))
.Where(x => x.Count >= 3)
.ToList();
// Warngebiete ohne Polygon (z.B. Seegebiete) können einer Schulkoordinate nicht
// zuverlässig zugeordnet werden und werden daher bewusst nicht global angezeigt.
if (polygons.Count == 0) continue;
result.Add(new ParsedWarning(new WeatherWarning
{
Identifier = root.Element(cap + "identifier")?.Value ?? "",
Event = info.Element(cap + "event")?.Value ?? "",
Headline = info.Element(cap + "headline")?.Value ?? "",
Description = info.Element(cap + "description")?.Value ?? "",
Instruction = info.Element(cap + "instruction")?.Value ?? "",
Severity = info.Element(cap + "severity")?.Value ?? "Unknown",
Onset = ParseDate(info.Element(cap + "onset")?.Value),
Expires = expires,
}, polygons));
}
return result;
}
private static List<double?> ParseValues(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => double.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
? (double?)value : null)
.ToList();
private static List<GeoPoint> ParsePolygon(string raw) => raw
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Split(','))
.Where(x => x.Length >= 2 &&
double.TryParse(x[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) &&
double.TryParse(x[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _))
.Select(x => new GeoPoint(
double.Parse(x[0], CultureInfo.InvariantCulture),
double.Parse(x[1], CultureInfo.InvariantCulture)))
.ToList();
public static bool PointInPolygon(IReadOnlyList<GeoPoint> polygon, double latitude, double longitude)
{
var inside = false;
for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
{
var a = polygon[i];
var b = polygon[j];
if ((a.Latitude > latitude) != (b.Latitude > latitude) &&
longitude < (b.Longitude - a.Longitude) * (latitude - a.Latitude) /
(b.Latitude - a.Latitude) + a.Longitude)
inside = !inside;
}
return inside;
}
private static bool Contains(IEnumerable<IReadOnlyList<GeoPoint>> polygons,
double latitude, double longitude) => polygons.Any(p => PointInPolygon(p, latitude, longitude));
private static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
{
const double earthRadiusKm = 6371.0;
static double Rad(double degrees) => degrees * Math.PI / 180.0;
var dLat = Rad(lat2 - lat1);
var dLon = Rad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
Math.Cos(Rad(lat1)) * Math.Cos(Rad(lat2)) *
Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
private static DateTime? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces,
out var parsed) ? parsed.UtcDateTime : null;
private static double? Round(double? value, int digits) => value is null
? null : Math.Round(value.Value, digits, MidpointRounding.AwayFromZero);
private static int SeverityRank(string severity) => severity switch
{
"Extreme" => 4, "Severe" => 3, "Moderate" => 2, "Minor" => 1, _ => 0,
};
public sealed record MosmixStation(string Id, string Name, double Latitude, double Longitude);
public sealed record ParsedForecast(DateTime? IssuedAt, List<WeatherForecastHour> Hours);
public sealed record GeoPoint(double Latitude, double Longitude);
public sealed record ParsedWarning(WeatherWarning Warning, List<List<GeoPoint>> Polygons);
private sealed record CachedForecast(DateTime LoadedAt, ParsedForecast Forecast);
private sealed record CachedWarnings(DateTime LoadedAt, List<ParsedWarning> Warnings);
}
+118
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;
@@ -149,6 +150,123 @@ public static class Endpoints
});
}
// ── Schulstandort und DWD-Wetter ─────────────────────────────────────────
public static void MapSchoolWeatherEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/school").RequireAuthorization();
g.MapGet("/location", (ClaimsPrincipal user, SchoolWeatherStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
return location is null
? Results.NotFound("Noch kein Schulstandort hinterlegt.")
: Results.Ok(location);
});
g.MapPut("/location", async ([FromBody] SchoolLocationRequest request,
ClaimsPrincipal user, SchoolWeatherStore store, IGeocoder geocoder,
CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var errors = ValidateLocation(request);
if (errors.Count > 0) return Results.ValidationProblem(errors);
var existing = store.GetLocation(uid);
if (existing is not null && SameLocation(existing, request))
return Results.Ok(existing);
GeocodingResult? result;
try { result = await geocoder.GeocodeAsync(request, cancellationToken); }
catch (HttpRequestException)
{
return Results.Problem("Der Geocoding-Dienst ist momentan nicht erreichbar.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return Results.Problem("Die Adressprüfung hat zu lange gedauert.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (result is null)
return Results.UnprocessableEntity(new
{
detail = "Die Schuladresse konnte nicht eindeutig gefunden werden. Bitte Schreibweise, PLZ und Ort prüfen.",
});
var profile = new SchoolLocationProfile
{
SchoolName = request.SchoolName.Trim(),
Street = request.Street.Trim(),
PostalCode = request.PostalCode.Trim(),
City = request.City.Trim(),
State = request.State,
Latitude = result.Latitude,
Longitude = result.Longitude,
ResolvedAddress = result.DisplayName,
UpdatedAt = DateTime.UtcNow,
};
store.SaveLocation(uid, profile);
return Results.Ok(profile);
});
g.MapGet("/weather", async (ClaimsPrincipal user, SchoolWeatherStore store,
DwdWeatherService weather, CancellationToken cancellationToken) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var location = store.GetLocation(uid);
if (location is null) return Results.NotFound("Noch kein Schulstandort hinterlegt.");
var cached = store.GetWeather(uid);
if (cached is not null && DateTime.UtcNow - cached.RetrievedAt.ToUniversalTime() < TimeSpan.FromMinutes(5))
return Results.Ok(cached);
try
{
var current = await weather.GetAsync(location, cancellationToken);
store.SaveWeather(uid, current);
return Results.Ok(current);
}
catch (Exception ex) when (ex is HttpRequestException or InvalidDataException or InvalidOperationException)
{
if (cached is not null)
{
cached.IsStale = true;
return Results.Ok(cached);
}
return Results.Problem("Der DWD ist momentan nicht erreichbar und es liegt noch kein Wetter-Cache vor.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
});
}
private static Dictionary<string, string[]> ValidateLocation(SchoolLocationRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.SchoolName))
errors[nameof(request.SchoolName)] = ["Bitte den Namen der Schule angeben."];
if (string.IsNullOrWhiteSpace(request.Street))
errors[nameof(request.Street)] = ["Bitte Straße und Hausnummer angeben."];
if (!System.Text.RegularExpressions.Regex.IsMatch(request.PostalCode?.Trim() ?? "", @"^\d{5}$"))
errors[nameof(request.PostalCode)] = ["Bitte eine fünfstellige deutsche PLZ angeben."];
if (string.IsNullOrWhiteSpace(request.City))
errors[nameof(request.City)] = ["Bitte den Ort angeben."];
if (!Enum.IsDefined(request.State))
errors[nameof(request.State)] = ["Das Bundesland ist ungültig."];
return errors;
}
private static bool SameLocation(SchoolLocationProfile existing, SchoolLocationRequest request) =>
string.Equals(existing.SchoolName, request.SchoolName.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.Street, request.Street.Trim(), StringComparison.OrdinalIgnoreCase) &&
string.Equals(existing.PostalCode, request.PostalCode.Trim(), StringComparison.Ordinal) &&
string.Equals(existing.City, request.City.Trim(), StringComparison.OrdinalIgnoreCase) &&
existing.State == request.State;
// ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret)
+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,
+78
View File
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public interface IGeocoder
{
Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address, CancellationToken cancellationToken);
}
public sealed record GeocodingResult(double Latitude, double Longitude, string DisplayName);
/// <summary>Einmalige, explizit ausgelöste Adressauflösung über den öffentlichen OSM-Nominatim-
/// Dienst. Kein Autocomplete; die dauerhafte Ergebnisspeicherung übernimmt SchoolWeatherStore.</summary>
public sealed class NominatimGeocoder(HttpClient http) : IGeocoder
{
private static readonly SemaphoreSlim RequestGate = new(1, 1);
private static DateTime _lastRequestAt = DateTime.MinValue;
public async Task<GeocodingResult?> GeocodeAsync(SchoolLocationRequest address,
CancellationToken cancellationToken)
{
var structured = Query(new Dictionary<string, string>
{
["street"] = address.Street,
["postalcode"] = address.PostalCode,
["city"] = address.City,
});
var result = await SearchRespectingRateLimitAsync(structured, cancellationToken);
if (result is not null) return result;
// Manche Schulen/Ortsteile sind bei OSM anders adressiert. Ein kontrollierter zweiter
// Versuch mit der gesamten Suchzeile ist robuster, bleibt aber weit weg vom 1-rps-Limit.
var freeForm = string.Join(", ", new[]
{
address.SchoolName, address.Street, $"{address.PostalCode} {address.City}", "Deutschland",
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return await SearchRespectingRateLimitAsync(
Query(new Dictionary<string, string> { ["q"] = freeForm }), cancellationToken);
}
private async Task<GeocodingResult?> SearchRespectingRateLimitAsync(string query,
CancellationToken cancellationToken)
{
await RequestGate.WaitAsync(cancellationToken);
try
{
var wait = TimeSpan.FromSeconds(1) - (DateTime.UtcNow - _lastRequestAt);
if (wait > TimeSpan.Zero) await Task.Delay(wait, cancellationToken);
_lastRequestAt = DateTime.UtcNow;
var candidates = await http.GetFromJsonAsync<List<NominatimCandidate>>(
$"search?format=jsonv2&limit=1&countrycodes=de{query}", cancellationToken);
var candidate = candidates?.FirstOrDefault();
if (candidate is null ||
!double.TryParse(candidate.Lat, NumberStyles.Float, CultureInfo.InvariantCulture, out var latitude) ||
!double.TryParse(candidate.Lon, NumberStyles.Float, CultureInfo.InvariantCulture, out var longitude))
return null;
return new(latitude, longitude, candidate.DisplayName ?? "");
}
finally { RequestGate.Release(); }
}
private static string Query(IReadOnlyDictionary<string, string> values) => string.Concat(
values.Where(x => !string.IsNullOrWhiteSpace(x.Value))
.Select(x => $"&{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value.Trim())}"));
private sealed class NominatimCandidate
{
[JsonPropertyName("lat")]
public string? Lat { get; set; }
[JsonPropertyName("lon")]
public string? Lon { get; set; }
[JsonPropertyName("display_name")]
public string? DisplayName { get; set; }
}
}
+19
View File
@@ -26,6 +26,8 @@ if (args.Length > 0 && args[0] == "set-password")
var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"]
?? "LehrerApp-Server/1.0 (+https://science-teaching.de)";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
@@ -81,10 +83,26 @@ builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>()));
builder.Services.AddSingleton<SchoolWeatherStore>(_ => new SchoolWeatherStore(data));
builder.Services.AddHttpClient<IGeocoder, NominatimGeocoder>(client =>
{
client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/");
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddHttpClient("dwd", client =>
{
client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddSingleton(sp => new DwdWeatherService(
sp.GetRequiredService<IHttpClientFactory>().CreateClient("dwd")));
var app = builder.Build();
app.UseForwardedHeaders();
app.UseRateLimiter();
// Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen.
app.UseMiddleware<SyncProtocolVersionMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints(secret);
@@ -93,5 +111,6 @@ app.MapAttachmentEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.MapSchoolWeatherEndpoints();
app.Run();
return 0;
+83
View File
@@ -0,0 +1,83 @@
using LehrerApp.Core.Models;
using LiteDB;
namespace LehrerApp.Api;
/// <summary>Unverschlüsselte Serverdaten, die der Server zur Abfrage standortbezogener öffentlicher
/// Wetterdaten benötigt. Bewusst getrennt vom clientseitig verschlüsselten EventStore.</summary>
public sealed class SchoolWeatherStore : IDisposable
{
private readonly LiteDatabase _db;
public SchoolWeatherStore(string dataPath)
{
Directory.CreateDirectory(dataPath);
_db = new LiteDatabase(Path.Combine(dataPath, "school-weather.db"));
}
private ILiteCollection<LocationEntry> Locations
{
get
{
var col = _db.GetCollection<LocationEntry>("locations");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
private ILiteCollection<WeatherEntry> Weather
{
get
{
var col = _db.GetCollection<WeatherEntry>("weather");
col.EnsureIndex(x => x.UserId, unique: true);
return col;
}
}
public SchoolLocationProfile? GetLocation(string userId) =>
Locations.FindOne(x => x.UserId == userId)?.Profile;
public void SaveLocation(string userId, SchoolLocationProfile profile)
{
var existing = Locations.FindOne(x => x.UserId == userId);
Locations.Upsert(new LocationEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Profile = profile,
});
// Ein geänderter Standort darf niemals den Wetterstand des alten Orts liefern.
Weather.DeleteMany(x => x.UserId == userId);
}
public WeatherSnapshot? GetWeather(string userId) =>
Weather.FindOne(x => x.UserId == userId)?.Snapshot;
public void SaveWeather(string userId, WeatherSnapshot snapshot)
{
var existing = Weather.FindOne(x => x.UserId == userId);
Weather.Upsert(new WeatherEntry
{
Id = existing?.Id ?? ObjectId.NewObjectId(),
UserId = userId,
Snapshot = snapshot,
});
}
public void Dispose() => _db.Dispose();
private sealed class LocationEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public SchoolLocationProfile Profile { get; set; } = new();
}
private sealed class WeatherEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string UserId { get; set; } = "";
public WeatherSnapshot Snapshot { get; set; } = new();
}
}
@@ -0,0 +1,44 @@
using System.Text.Json;
using LehrerApp.Sync;
namespace LehrerApp.Api;
/// <summary>
/// Stoppt veraltete Sync-Clients vor Authentifizierung und insbesondere vor jedem Store-Zugriff.
/// Die Prüfung gilt auch für Anhänge, Plain-Sync und Device-Pairing-Snapshots.
/// </summary>
public sealed class SyncProtocolVersionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
if (!IsProtectedSyncPath(context.Request.Path))
{
await next(context);
return;
}
context.Response.Headers[SyncProtocol.VersionHeaderName] = SyncProtocol.CurrentVersion;
var clientVersion = context.Request.Headers[SyncProtocol.VersionHeaderName].ToString();
if (string.Equals(clientVersion, SyncProtocol.CurrentVersion, StringComparison.Ordinal))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
context.Response.ContentType = "application/problem+json";
await JsonSerializer.SerializeAsync(context.Response.Body, new
{
type = "https://lehrerapp.local/problems/sync-version-mismatch",
title = "Inkompatible Sync-Version",
status = StatusCodes.Status426UpgradeRequired,
detail = $"Der Server erwartet Sync-Version {SyncProtocol.CurrentVersion}.",
expectedVersion = SyncProtocol.CurrentVersion,
receivedVersion = string.IsNullOrEmpty(clientVersion) ? null : clientVersion,
});
}
private static bool IsProtectedSyncPath(PathString path) =>
path.StartsWithSegments("/api/sync", StringComparison.OrdinalIgnoreCase) ||
path.StartsWithSegments("/api/snapshot", StringComparison.OrdinalIgnoreCase);
}
@@ -119,3 +119,58 @@ public class AiExplainResponse
{
public string Explanation { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für den Gefährdungsbeurteilungs-Entwurf (ai-backend/gbu.php, Nutzerwunsch neben
/// 4.2 "Anhänge je Stunde"): erkennt aus Thema/Verlaufsplan der Lesson das Experiment und liefert
/// dafür einen strukturierten Entwurf. Eigener Endpunkt statt Zusatzfeld in plan.php/explain.php,
/// da inhaltlich unabhängig von der Unterrichtsplanung selbst.
/// </summary>
public class AiHazardAssessmentRequest
{
public AiUnitContext Unit { get; set; } = new();
public AiLesson Lesson { get; set; } = new();
}
public class AiHazardAssessmentResponse
{
/// "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch" (siehe ExperimentKind).
public string ExperimentKind { get; set; } = "";
public string Procedure { get; set; } = "";
public List<AiHazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> ProtectiveMeasures { get; set; } = [];
public string FirstAid { get; set; } = "";
public string Disposal { get; set; } = "";
}
public class AiHazardSubstance
{
public string Name { get; set; } = "";
public string Amount { get; set; } = "";
/// GHS-Piktogramm-Codes als Text, z.B. "GHS05" — siehe GhsPictogram für die Zuordnung.
public List<string> GhsPictograms { get; set; } = [];
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
}
/// <summary>
/// Wire-Vertrag für die Chemikalien-Recherche (ai-backend/substance.php, Nutzerwunsch neben 4.2):
/// ein reiner Datenbank-Lookup gegen die lokale RiSU-Stoffliste, kein KI-Aufruf (siehe
/// ai-backend/stoffliste.php) — der Name "Ai..." bleibt trotzdem bestehen, da der Aufruf über
/// dieselbe Anmeldung/denselben Host wie die echten KI-Endpunkte läuft. Eigener, sehr schmaler
/// Wire-Vertrag statt Wiederverwendung von AiHazardSubstance — die Recherche liefert
/// zusätzliche, nur hier relevante Felder (CAS, Signalwort, Tätigkeitsbeschränkung, Quelle).
/// Enthält bewusst KEIN Entsorgungsfeld — die RiSU-Stoffliste führt keine Entsorgungshinweise.
/// </summary>
public class AiSubstanceResearchResponse
{
public string Name { get; set; } = "";
public string Cas { get; set; } = "";
public List<string> GhsPictograms { get; set; } = [];
public string SignalWord { get; set; } = "";
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
public string ActivityRestriction { get; set; } = "";
public string Source { get; set; } = "";
}
@@ -25,6 +25,9 @@ public interface ISeatingPlanRepository
List<SeatingPlan> GetByGroup(Guid groupId);
void Save(SeatingPlan plan);
void Delete(Guid id);
/// <summary>Stellt einen über <see cref="Delete"/> in den Papierkorb verschobenen Sitzplan
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
void Restore(Guid trashId);
}
public interface IGroupMembershipRepository
{
@@ -57,6 +60,8 @@ public interface IGradingKeyTemplateRepository
GradingKeyTemplate? GetById(Guid id);
void Save(GradingKeyTemplate template);
void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
}
public interface IGradeRepository
{
@@ -64,6 +69,8 @@ public interface IGradeRepository
List<Grade> GetByGroup(Guid groupId);
void Save(Grade grade);
void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
}
public interface IGradingSchemeRepository
{
@@ -117,9 +124,70 @@ public interface ISubstitutionEntryRepository
{
List<SubstitutionEntry> GetAll();
List<SubstitutionEntry> GetByDate(DateOnly date);
/// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID).
SubstitutionEntry? GetByExternalId(string externalId);
void Save(SubstitutionEntry entry);
void Delete(Guid id);
}
/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf.
public interface IUntisSnapshotRepository
{
List<UntisSnapshotEntry> GetAll();
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);
}
/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup.
public interface IUntisSlotMappingRepository
{
List<UntisSlotMapping> GetAll();
void Save(UntisSlotMapping mapping);
void Delete(Guid id);
}
/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis-
/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben
/// externen Feed selbst.
public interface IAnnualPlanEventRepository
{
List<AnnualPlanEvent> GetAll();
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
AnnualPlanEvent? GetByExternalId(string externalId);
void Save(AnnualPlanEvent entry);
void Delete(Guid id);
}
public interface IDocumentationRepository
{
List<Documentation> GetByStudent(Guid studentId);
@@ -136,8 +204,12 @@ public interface IWorkTaskRepository
{
List<WorkTask> GetByStatus(WorkTaskStatus status);
List<WorkTask> GetAll();
/// Für das "Anstehende Aufgaben"-Widget im Kurs-Dashboard (9.2/7.2).
List<WorkTask> GetByGroup(Guid groupId);
void Save(WorkTask task);
void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
}
public interface ITimeEntryRepository
{
@@ -146,11 +218,16 @@ public interface ITimeEntryRepository
List<TimeEntry> GetByTask(Guid taskId);
void Save(TimeEntry entry);
void Delete(Guid id);
/// <summary>Siehe ISeatingPlanRepository.Restore (14.3).</summary>
void Restore(Guid trashId);
}
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);
}
@@ -209,3 +286,11 @@ public interface IAlternativeLessonPathRepository
void Save(AlternativeLessonPath path);
void Delete(Guid id);
}
/// <summary>Papierkorb (14.3) — reine Übersicht/Aufräumen; das eigentliche Wiederherstellen läuft
/// über die jeweilige Fach-Repository (z.B. IGradeRepository.Restore), da nur die kennt, wie ein
/// Schnappschuss ihres Modells korrekt gespeichert wird (Validierung, OnChange-Ereignis).</summary>
public interface ITrashRepository
{
List<TrashedItem> GetAll();
void PurgeOlderThan(DateTime cutoffUtc);
}
+37
View File
@@ -0,0 +1,37 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
/// </summary>
public class AnnualPlanEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
public string ExternalId { get; set; } = "";
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string Location { get; set; } = "";
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
/// <summary>
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
/// lokale Schulzeit Europe/Berlin umgerechnet.
/// </summary>
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public bool IsAllDay { get; set; }
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
public string CalendarGroup { get; set; } = "";
public string Color { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public int Sequence { get; set; }
public DateTime? SourceLastModifiedUtc { get; set; }
}
+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;
}
+62
View File
@@ -0,0 +1,62 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Gefährdungsbeurteilung zu einem Experiment (fachspezifisch, v.a. Chemie/Naturwissenschaften).
/// Lebt NICHT als eigene LiteDB-Collection/Repository, sondern wird als JSON serialisiert und
/// über die bestehende <see cref="DocumentAttachment"/>-Infrastruktur an eine <see cref="Lesson"/>
/// angehängt (Dateiname endet auf ".gbu.json", siehe TODO.md 4.2) — das reicht für Bearbeiten,
/// Anzeigen und PDF-Export, ohne eine weitere synchronisierte Entität samt Repository/DI-Wiring
/// einzuführen (bewusste Scope-Entscheidung, siehe TODO.md).
/// </summary>
public class HazardAssessment
{
public string Title { get; set; } = "";
/// Klassenstufe/Kurs, z.B. "10c" — vorbelegt aus der zugehörigen Lerngruppe, frei änderbar.
public string GroupLabel { get; set; } = "";
public DateOnly? Date { get; set; }
public ExperimentKind Kind { get; set; } = ExperimentKind.Lehrerversuch;
/// Kurzbeschreibung der Durchführung.
public string Procedure { get; set; } = "";
public List<HazardSubstance> Substances { get; set; } = [];
public List<string> Hazards { get; set; } = [];
public List<string> ProtectiveMeasures { get; set; } = [];
public string FirstAid { get; set; } = "";
public string Disposal { get; set; } = "";
public string Notes { get; set; } = "";
/// Ob ein KI-Entwurf beteiligt war — steuert den Rechtssicherheits-Hinweis im PDF-Export.
public bool IsAiAssisted { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class HazardSubstance
{
public string Name { get; set; } = "";
/// CAS-Nummer, falls über die Chemikalien-Recherche (TODO.md 4.2-Nachtrag) ermittelt oder von
/// Hand eingetragen — optional, nur zur eindeutigeren Wiedererkennung/Recherche.
public string Cas { get; set; } = "";
/// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der
/// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist.
public string Amount { get; set; } = "";
public List<GhsPictogram> GhsPictograms { get; set; } = [];
/// "Gefahr" oder "Achtung" (GHS-Signalwort) — Freitext statt Enum, da GESTIS-Recherchen auch
/// mal ohne eindeutiges Signalwort zurückkommen können.
public string SignalWord { get; set; } = "";
/// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein
/// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei
/// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt.
public string HStatements { get; set; } = "";
public string PStatements { get; set; } = "";
/// Tätigkeitsbeschränkung laut RiSU-Stoffliste (Freigabe für alle/Schulversuche/nur
/// Lehrkraft, Altersgrenzen, Ersatzstoffprüfung nötig, verboten, ...) — Freitext, da die
/// tatsächlichen Formulierungen zu unterschiedlich für ein starres Feld/Enum sind.
public string ActivityRestriction { get; set; } = "";
}
public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch }
/// Die neun GHS-Gefahrenpiktogramme (Global Harmonisiertes System).
public enum GhsPictogram
{
Explosive, Flammable, Oxidizing, CompressedGas, Corrosive,
Toxic, Harmful, HealthHazard, Environmental,
}
+13
View File
@@ -20,6 +20,19 @@ 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>
/// 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,
};
}
}
+15 -2
View File
@@ -43,7 +43,7 @@ public class Unit
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Lesson
public class Lesson : IHasAttachments
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UnitId { get; set; }
@@ -64,6 +64,10 @@ public class Lesson
public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { 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
/// bei <see cref="Documentation"/>.
public List<DocumentAttachment> Attachments { get; set; } = [];
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -96,7 +100,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 }
/// <summary>
/// Katalogeintrag für einen wiederverwendbaren "alternativen Ablauf" (z.B. "Kurzversion" bei
@@ -206,6 +213,12 @@ public class SubstitutionEntry
/// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse").
public string Description { get; set; } = "";
public string? Notes { get; set; }
/// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist —
/// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe
/// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und
/// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen
/// (SubstitutionEntryDialogViewModel) bleibt es null.
public string? ExternalId { get; set; }
}
/// <summary>
+13
View File
@@ -23,6 +23,13 @@ public class SeatingPlan
/// </summary>
public List<double> ColumnGapWidths { get; set; } = [];
public List<SeatAssignment> Assignments { get; set; } = [];
/// <summary>
/// Plätze, für die im Raum physisch kein Tisch existiert (z. B. eine Reihe mit einer Lücke) —
/// werden im Sitzplan ausgeblendet, bleiben aber Teil des Rasters, damit Reihen/Spalten
/// weiterhin korrekt ausgerichtet sind. Ein ausgeblendeter Platz kann nie belegt sein, siehe
/// SeatingPlanRepository.Save.
/// </summary>
public List<HiddenSeat> HiddenSeats { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -33,3 +40,9 @@ public class SeatAssignment
public int Column { get; set; }
public Guid StudentId { get; set; }
}
public class HiddenSeat
{
public int Row { get; set; }
public int Column { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Papierkorb-Eintrag (14.3): ein gelöschter Datensatz, als vollständiger JSON-Schnappschuss
/// aufbewahrt, damit er wiederhergestellt werden kann. Deckt bewusst nur Löschvorgänge OHNE
/// Kaskade ab (z.B. eine einzelne Note, keine ganze Lerngruppe) — Details siehe
/// <c>LiteDbContext.MoveToTrash</c>/<c>RestoreFromTrash</c> und TODO.md 14.3.
/// </summary>
public class TrashedItem
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Modellname (<c>nameof(Grade)</c> etc.) — bestimmt, wie der Schnappschuss beim
/// Wiederherstellen deserialisiert und welche Repository-Methode aufgerufen wird.</summary>
public string EntityType { get; set; } = "";
public Guid EntityId { get; set; }
/// <summary>Vollständiger JSON-Schnappschuss der gelöschten Entität.</summary>
public string Snapshot { get; set; } = "";
/// <summary>Kurze, für die Papierkorb-Liste lesbare Beschreibung (z.B. "Note 2 (12.09.2026)").</summary>
public string Summary { get; set; } = "";
public DateTime DeletedAt { get; set; } = DateTime.UtcNow;
}
+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; }
}
+69
View File
@@ -0,0 +1,69 @@
namespace LehrerApp.Core.Models;
/// <summary>
/// Zuletzt bekannter Zustand eines einzelnen WebUntis-iCal-Termins (ein VEVENT) — die lokale
/// "Sicherungskopie", gegen die jeder neue Abruf verglichen wird, um Änderungen zu erkennen
/// (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). <see cref="Uid"/>
/// ist die stabile iCal-UID des Termins (pro Wochen-Slot über das ganze Schuljahr gleich) und
/// dient als fachlicher Schlüssel für den Abgleich — bewusst kein <c>[BsonId]</c> darauf, da
/// LehrerApp.Core absichtlich frei von LiteDB/Avalonia-Abhängigkeiten bleibt (siehe CLAUDE.md);
/// die Suche nach <see cref="Uid"/> läuft stattdessen über eine gefilterte Repository-Abfrage.
/// </summary>
public class UntisSnapshotEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Vom Nutzer bestätigte Zuordnung eines regulären WebUntis-Wochenmusters (Wochentag + Uhrzeit +
/// Fach-Kürzel + Klassen-Token aus der Beschreibung) zu einer bestehenden <see cref="LearningGroup"/>
/// (<see cref="SubstitutionKind.Lesson"/>) oder — für Termine ohne Klassenbezug, z.B. Aufsichten —
/// zu einer festen Pause (<see cref="SubstitutionKind.Supervision"/>, <see cref="AfterPeriod"/>
/// statt <see cref="GroupId"/>/<see cref="PeriodNumber"/>; Nutzer-Feedback: "Zwei Termine sind
/// meine Aufsichten, die nicht zugeordnet werden können [...] vom Zeitraster und von der Dauer her
/// könnten die erfasst werden" — Aufsichten liegen in Pausen, nicht auf einem Unterrichtsstunden-
/// Zeitraster, brauchen also einen eigenen Auflösungsweg statt PeriodNumber). Nur bestätigte
/// (<see cref="Confirmed"/> true) Zuordnungen lösen automatisch geschriebene
/// <see cref="SubstitutionEntry"/>-Einträge aus (Nutzer-Feedback: die erstmalige Zuordnung ist
/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine
/// Bestätigung, bevor sie aktiv wird; siehe UntisMappingReviewDialog).
/// </summary>
public class UntisSlotMapping
{
public Guid Id { get; set; } = Guid.NewGuid();
public DayOfWeek Weekday { get; set; }
public TimeOnly StartTime { get; set; }
public string? Summary { get; set; }
public string ClassToken { get; set; } = "";
public SubstitutionKind Kind { get; set; } = SubstitutionKind.Lesson;
/// Nur bei <see cref="SubstitutionKind.Lesson"/> gesetzt.
public Guid? GroupId { get; set; }
/// Erste/primäre Stunde, zum Bestätigungszeitpunkt über PeriodScheduleService aufgelöst (siehe
/// UntisMatchingService) — hier gespeichert, damit UntisDiffService selbst framework-frei
/// bleibt und keine eigene Uhrzeit→Stunde-Auflösung braucht. Nur bei
/// <see cref="SubstitutionKind.Lesson"/> gesetzt.
public int? PeriodNumber { get; set; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt (siehe UntisSlotMatch.CoveredPeriods) —
/// bei einer Doppelstunde mehr als eine. Entscheidet, welche TimetableSlots als "durch WebUntis
/// bestätigt" gelten (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem
/// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" — ohne diese Liste blieb
/// die zweite Stunde einer Doppelstunde immer "unzugeordnet", weil WebUntis dafür nur einen
/// einzigen, am Anfang beginnenden Termin meldet). Nur bei <see cref="SubstitutionKind.Lesson"/>
/// gesetzt.
public List<int> CoveredPeriods { get; set; } = [];
/// Nur bei <see cref="SubstitutionKind.Supervision"/> gesetzt — wie bei
/// <see cref="SupervisionDuty.AfterPeriod"/>, ebenfalls zum Bestätigungszeitpunkt aufgelöst
/// (die Pause direkt vor der WebUntis-Startzeit, 0 = vor der 1. Stunde).
public int? AfterPeriod { get; set; }
public bool Confirmed { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+63
View File
@@ -0,0 +1,63 @@
namespace LehrerApp.Core.Models;
/// <summary>Vom Nutzer eingegebene Schuladresse. Die Geocodierung erfolgt ausschließlich
/// serverseitig beim expliziten Speichern, nicht während der Eingabe.</summary>
public class SchoolLocationRequest
{
public string SchoolName { get; set; } = "";
public string Street { get; set; } = "";
public string PostalCode { get; set; } = "";
public string City { get; set; } = "";
public GermanState State { get; set; } = GermanState.NW;
}
/// <summary>Serverseitig gespeicherter Schulstandort einschließlich des bestätigbaren
/// Geocoding-Ergebnisses. Koordinaten werden nie an den DWD als Straßenadresse übertragen.</summary>
public sealed class SchoolLocationProfile : SchoolLocationRequest
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string ResolvedAddress { get; set; } = "";
public DateTime UpdatedAt { get; set; }
}
public sealed class WeatherSnapshot
{
public string StationId { get; set; } = "";
public string StationName { get; set; } = "";
public double StationDistanceKm { get; set; }
public DateTime? ForecastIssuedAt { get; set; }
public DateTime RetrievedAt { get; set; }
/// <summary>True, wenn der DWD vorübergehend nicht erreichbar war und der Server den zuletzt
/// erfolgreich gespeicherten Stand zurückgibt.</summary>
public bool IsStale { get; set; }
public List<WeatherForecastHour> Forecast { get; set; } = [];
public List<WeatherWarning> Warnings { get; set; } = [];
}
public sealed class WeatherForecastHour
{
public DateTime ValidAt { get; set; }
public double? TemperatureC { get; set; }
public double? WindSpeedKmh { get; set; }
public double? WindGustKmh { get; set; }
public double? PrecipitationMm { get; set; }
/// <summary>Stündliche MOSMIX-Wahrscheinlichkeit für mehr als 0,1 mm Niederschlag.</summary>
public double? PrecipitationProbabilityPercent { get; set; }
/// <summary>MOSMIX-Sonnenscheindauer der letzten Stunde, in Minuten.</summary>
public double? SunshineDurationMinutes { get; set; }
public double? CloudCoverPercent { get; set; }
public int? WeatherCode { get; set; }
}
public sealed class WeatherWarning
{
public string Identifier { get; set; } = "";
public string Event { get; set; } = "";
public string Headline { get; set; } = "";
public string Description { get; set; } = "";
public string Instruction { get; set; } = "";
public string Severity { get; set; } = "";
public DateTime? Onset { get; set; }
public DateTime? Expires { get; set; }
}
+50 -2
View File
@@ -1,10 +1,14 @@
namespace LehrerApp.Core.Models;
public class Documentation
public class Documentation : IHasAttachments
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid? GroupId { get; set; }
/// <summary>Unterrichtssitzung, in der die Beobachtung entstanden ist.</summary>
public Guid? ParticipationSessionId { get; set; }
/// <summary>Optionaler Bezug auf die konkrete geplante Stunde.</summary>
public Guid? LessonId { get; set; }
public DocumentationType Type { get; set; }
public DateOnly Date { get; set; }
public string Title { get; set; } = "";
@@ -17,7 +21,13 @@ public class Documentation
public List<DocumentAttachment> Attachments { get; set; } = [];
/// Freie Labels zur Nachverfolgung, z.B. "Kritisch", "Nacharbeiten" — siehe `DocumentationTagDisplay`.
public List<string> Tags { get; set; } = [];
/// <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).
@@ -71,8 +81,19 @@ public class DocumentAttachment
public long SizeBytes { get; set; }
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Implementiert von jedem Modell mit Datei-Anhängen (aktuell <see cref="Documentation"/> und
/// <see cref="Lesson"/>). Anhang-Bytes reisen nicht im JSON-Sync-Ereignis mit, sondern als eigener
/// Binärtransfer (siehe <c>SyncEventPublisher</c>/<c>EventApplier</c>/<c>AttachmentSyncer</c> in
/// LehrerApp.Sync) — dieses Interface macht die Erkennung "hat dieses Modell Anhänge?" dort
/// generisch statt hart auf einen einzelnen Modelltyp verdrahtet.
/// </summary>
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
@@ -87,9 +108,34 @@ public class WorkTask
public string? Notes { get; set; }
// Beim Abschließen (6.1.3) wird bei Weekly/Monthly automatisch die nächste Aufgabe erzeugt (6.1.4).
public TaskRecurrence Recurrence { get; set; } = TaskRecurrence.None;
/// Nutzer-Feedback: "Aufgaben" wirkt wie reine Zeiterfassung, dabei braucht man auch schlichte
/// Erinnerungen ohne Zeitbezug (z.B. "morgen Ansage an die Klasse machen"). Reminder-Aufgaben
/// laufen über dieselbe Liste/dasselbe Dashboard-Widget, werden aber nie mit einem
/// TimeEntry verknüpft und fließen deshalb schon rein datenmodell-bedingt (WorkloadEvaluationViewModel
/// wertet nur TimeEntry aus, nicht WorkTask) nicht in die Zeitauswertung (6.3) ein.
public TaskKind Kind { get; set; } = TaskKind.WorkItem;
/// Nutzer-Feedback: pädagogische Erinnerungen ("Ansage an die Klasse", "Material einsammeln")
/// sollen sich je nach Dringlichkeit farblich absetzen können, unabhängig vom Fälligkeitsdatum.
public TaskPriority Priority { get; set; } = TaskPriority.Normal;
/// Optionale Abhak-Liste (Nutzer-Feedback: "Namensliste aller Kurs-Schüler" ODER freie
/// Teil-Punkte, wahlweise). Beides läuft über denselben Listentyp — bei aus der Kursliste
/// befüllten Einträgen ist StudentId gesetzt (Sprungziel zum Schüler), bei frei eingegebenen
/// Einträgen bleibt es leer. Als verschachtelte LiteDB-Liste statt eigener Collection/JSON-
/// String, siehe TODO.md-Nachtrag zu 6.1/9 — synct dadurch automatisch über den bestehenden
/// Sync-Unterbau mit (EventPublisher serialisiert das ganze WorkTask-Dokument je Save()).
public List<ChecklistItem> ChecklistItems { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class ChecklistItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Label { get; set; } = "";
public bool IsDone { get; set; }
/// Gesetzt, wenn der Eintrag aus der Kursliste befüllt wurde (Sprungziel zum Schüler);
/// bei frei eingegebenen Einträgen null.
public Guid? StudentId { get; set; }
}
public class TimeEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -106,3 +152,5 @@ public class TimeEntry
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other, Teaching }
public enum WorkTaskStatus { Open, InProgress, Done }
public enum TaskRecurrence { None, Weekly, Monthly }
public enum TaskKind { WorkItem, Reminder }
public enum TaskPriority { Low, Normal, High }
@@ -0,0 +1,205 @@
using System.Globalization;
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>
/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen
/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse.
/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der
/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht.
/// </summary>
public static class AnnualPlanIcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly record struct Property(string Head, string Value)
{
public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase);
}
public static List<AnnualPlanEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) ||
!lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase)))
throw new FormatException("Kein vollständiger iCal-Kalender gefunden.");
var result = new List<AnnualPlanEvent>();
Dictionary<string, Property>? current = null;
foreach (var line in lines)
{
if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden.");
current = new Dictionary<string, Property>(StringComparer.OrdinalIgnoreCase);
continue;
}
if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase))
{
if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden.");
result.Add(BuildEvent(current, result.Count + 1));
current = null;
continue;
}
if (current is null) continue;
var colon = line.IndexOf(':');
if (colon < 0) continue;
var head = line[..colon];
var separator = head.IndexOf(';');
var key = separator < 0 ? head : head[..separator];
if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]);
}
if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden.");
if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count)
throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs.");
return result;
}
private static AnnualPlanEvent BuildEvent(Dictionary<string, Property> properties, int number)
{
var uid = Required(properties, "UID", number).Value.Trim();
if (uid.Length == 0) throw Invalid(number, "UID fehlt");
var startProperty = Required(properties, "DTSTART", number);
var endProperty = properties.GetValueOrDefault("DTEND");
DateOnly startDate;
DateOnly endDate;
TimeOnly? startTime;
TimeOnly? endTime;
var isAllDay = startProperty.IsDate;
if (isAllDay)
{
startDate = ParseDate(startProperty.Value, number, "DTSTART");
var endExclusive = endProperty == default
? startDate.AddDays(1)
: endProperty.IsDate
? ParseDate(endProperty.Value, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART");
endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv.
startTime = null;
endTime = null;
}
else
{
var start = ParseLocalDateTime(startProperty, number, "DTSTART");
var end = endProperty == default
? start
: !endProperty.IsDate
? ParseLocalDateTime(endProperty, number, "DTEND")
: throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen");
if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART");
startDate = DateOnly.FromDateTime(start);
endDate = DateOnly.FromDateTime(end);
startTime = TimeOnly.FromDateTime(start);
endTime = TimeOnly.FromDateTime(end);
}
return new AnnualPlanEvent
{
ExternalId = uid,
Title = Text(properties, "SUMMARY"),
Description = Text(properties, "DESCRIPTION"),
Location = Text(properties, "LOCATION"),
StartDate = startDate,
EndDate = endDate,
StartTime = startTime,
EndTime = endTime,
IsAllDay = isAllDay,
CalendarGroup = Text(properties, "X-GROUPNAME"),
Color = Text(properties, "X-COLOR"),
Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED",
Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer,
CultureInfo.InvariantCulture, out var sequence) ? sequence : 0,
SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number),
};
}
private static Property Required(Dictionary<string, Property> properties, string key, int number) =>
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
private static string Raw(Dictionary<string, Property> properties, string key) =>
properties.TryGetValue(key, out var value) ? value.Value : "";
private static string Text(Dictionary<string, Property> properties, string key) =>
Unescape(Raw(properties, key));
private static DateOnly ParseDate(string value, int number, string field)
{
if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var date)) return date;
throw Invalid(number, $"ungültiges {field}: {value}");
}
private static DateTime ParseLocalDateTime(Property property, int number, string field)
{
var utc = property.Value.EndsWith('Z');
var value = utc ? property.Value[..^1] : property.Value;
var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" };
if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
throw Invalid(number, $"ungültiges {field}: {property.Value}");
return utc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified);
}
private static DateTime? ParseOptionalUtc(Property property, int number)
{
if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null;
var local = ParseLocalDateTime(property, number, "LAST-MODIFIED");
return property.Value.EndsWith('Z')
? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone)
: TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone);
}
private static List<string> Unfold(string text)
{
var result = new List<string>();
foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
{
if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0)
result[^1] += line[1..];
else
result.Add(line);
}
return result;
}
private static string Unescape(string value)
{
var result = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] != '\\' || i + 1 >= value.Length)
{
result.Append(value[i]);
continue;
}
var next = value[++i];
result.Append(next switch
{
'n' or 'N' => '\n',
'\\' => '\\',
',' => ',',
';' => ';',
_ => next,
});
}
return result.ToString();
}
private static FormatException Invalid(int number, string message) =>
new($"Ungültiger Jahresplan-Termin #{number}: {message}.");
}
+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))
@@ -15,7 +15,8 @@ public sealed class DashboardSettingsService
public static readonly string[] DefaultCardOrder =
[
"today", "tasks", "calendar", "excuses", "upcoming",
"corrections", "unplanned", "alerts", "attendance", "support", "groups",
"corrections", "unplanned", "alerts", "attendance", "support", "groups", "examload",
"missingteachingtime",
];
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;
+143
View File
@@ -0,0 +1,143 @@
using System.Globalization;
using System.Text;
namespace LehrerApp.Core.Services;
/// <summary>Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem
/// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich").</summary>
public class UntisIcsEvent
{
public string Uid { get; set; } = "";
public DateOnly Date { get; set; }
public DayOfWeek Weekday => Date.DayOfWeek;
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public string? Summary { get; set; }
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Status { get; set; } = "CONFIRMED";
}
/// <summary>
/// Bewusst kein vollständiger RFC-5545-Parser, sondern genau der Ausschnitt, den der reale
/// WebUntis-Export tatsächlich nutzt (verifiziert an einem echten Export, siehe Planungsdokument):
/// flache VEVENT-Liste ohne RRULE-Wiederholung, ohne Line-Folding, ohne VALARM. `DTSTART;TZID=...`
/// wird als lokale Wanduhrzeit gelesen (die App kennt ohnehin nur "lokale Zeit" der Schule, siehe
/// PeriodScheduleService) — ein `...Z`-Suffix (UTC) wird defensiv unterstützt, auch wenn im realen
/// Export nicht beobachtet. Termine mit unbekanntem DTSTART-Format werden übersprungen statt die
/// gesamte Verarbeitung abzubrechen (gleiche Fail-soft-Haltung wie EventApplier in LehrerApp.Sync).
/// </summary>
public static class IcsParser
{
private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static List<UntisIcsEvent> Parse(string icsText)
{
var lines = Unfold(icsText);
var events = new List<UntisIcsEvent>();
Dictionary<string, (string Params, string Value)>? current = null;
foreach (var line in lines)
{
if (line == "BEGIN:VEVENT") { current = []; continue; }
if (line == "END:VEVENT")
{
if (current is not null && TryBuildEvent(current, out var evt)) events.Add(evt);
current = null;
continue;
}
if (current is null) continue;
var (key, paramsPart, value) = SplitPropertyLine(line);
if (key.Length == 0) continue;
current[key] = (paramsPart, value);
}
return events;
}
// RFC 5545 Line-Folding: eine Fortsetzungszeile beginnt mit Leerzeichen/Tab und gehört zur
// Vorzeile — im realen WebUntis-Export nicht beobachtet (Zeilen deutlich unter dem 75-Oktett-
// Limit), aber defensiv unterstützt, falls sich der Export einmal ändert.
private static List<string> Unfold(string icsText)
{
var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
var result = new List<string>();
foreach (var raw in rawLines)
{
if ((raw.StartsWith(' ') || raw.StartsWith('\t')) && result.Count > 0)
result[^1] += raw[1..];
else
result.Add(raw);
}
return result;
}
// "KEY;PARAM=VAL;PARAM2=VAL2:VALUE" — der erste unescapte Doppelpunkt trennt Wert vom Rest.
private static (string Key, string Params, string Value) SplitPropertyLine(string line)
{
var colonIndex = line.IndexOf(':');
if (colonIndex < 0) return ("", "", "");
var head = line[..colonIndex];
var value = line[(colonIndex + 1)..];
var semiIndex = head.IndexOf(';');
return semiIndex < 0 ? (head, "", value) : (head[..semiIndex], head[(semiIndex + 1)..], value);
}
private static bool TryBuildEvent(Dictionary<string, (string Params, string Value)> props, out UntisIcsEvent evt)
{
evt = new UntisIcsEvent();
if (!props.TryGetValue("UID", out var uid) || string.IsNullOrWhiteSpace(uid.Value)) return false;
evt.Uid = uid.Value;
if (!props.TryGetValue("DTSTART", out var dtStart) || !TryParseLocalDateTime(dtStart, out var start))
return false;
evt.Date = DateOnly.FromDateTime(start);
evt.StartTime = TimeOnly.FromDateTime(start);
if (props.TryGetValue("DTEND", out var dtEnd) && TryParseLocalDateTime(dtEnd, out var end))
evt.EndTime = TimeOnly.FromDateTime(end);
evt.Summary = props.TryGetValue("SUMMARY", out var summary) && !string.IsNullOrWhiteSpace(summary.Value)
? Unescape(summary.Value) : null;
evt.Location = props.TryGetValue("LOCATION", out var location) ? Unescape(location.Value) : "";
evt.Description = props.TryGetValue("DESCRIPTION", out var description) ? Unescape(description.Value) : "";
evt.Status = props.TryGetValue("STATUS", out var status) && !string.IsNullOrWhiteSpace(status.Value)
? status.Value : "CONFIRMED";
return true;
}
// "TZID=Europe/Berlin:20260817T075000" (lokale Wanduhrzeit) oder "20260817T075000Z" (UTC).
private static bool TryParseLocalDateTime((string Params, string Value) prop, out DateTime local)
{
local = default;
var value = prop.Value;
var isUtc = value.EndsWith('Z');
var digits = isUtc ? value[..^1] : value;
if (!DateTime.TryParseExact(digits, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var parsed))
return false;
local = isUtc
? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone)
: parsed;
return true;
}
private static string Unescape(string value)
{
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
var next = value[i + 1];
sb.Append(next switch { ';' => ';', ',' => ',', 'n' or 'N' => '\n', '\\' => '\\', _ => next });
i++;
}
else sb.Append(value[i]);
}
return sb.ToString();
}
}
@@ -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 == LessonStatus.Conducted || 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 == LessonStatus.Conducted ? 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,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);
}
}
+228
View File
@@ -0,0 +1,228 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public sealed class UntisDiffResult
{
/// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über
/// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert
/// statt dupliziert werden muss; siehe UntisSyncService).
public List<SubstitutionEntry> SubstitutionsToSave { get; init; } = [];
public List<UntisSnapshotEntry> SnapshotToSave { get; init; } = [];
/// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt,
/// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird.
public List<Guid> SnapshotIdsToDelete { get; init; } = [];
/// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht
/// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine
/// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend
/// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so"
/// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte
/// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim
/// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen.
/// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService).
public List<string> SubstitutionExternalIdsToDelete { get; init; } = [];
}
/// <summary>
/// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten
/// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden").
/// Framework-frei wie <see cref="UntisMatchingService"/> — nimmt nur einfache Objekte/Listen
/// entgegen, kein Datenbankzugriff.
/// </summary>
public class UntisDiffService
{
public const int DefaultLookaheadDays = 14;
public UntisDiffResult Diff(List<UntisIcsEvent> newEvents, List<UntisSnapshotEntry> previousSnapshot,
List<UntisSlotMapping> confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays,
List<SupervisionDuty>? existingSupervisionDuties = null, HashSet<DateOnly>? freeDates = null)
{
var previousByUid = previousSnapshot.ToDictionary(s => s.Uid);
// GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot
// (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer
// ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen.
var mappingByWeekdayStart = confirmedMappings
.Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null))
.GroupBy(m => (m.Weekday, m.StartTime))
.ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First());
// Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe
// SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die
// KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll
// deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen.
var regularDutyKeys = (existingSupervisionDuties ?? [])
.Select(d => (d.Weekday, d.AfterPeriod))
.ToHashSet();
var substitutions = new List<SubstitutionEntry>();
var snapshotToSave = new List<UntisSnapshotEntry>();
var externalIdsToDelete = new List<string>();
var newEventsByUid = newEvents.ToDictionary(e => e.Uid);
foreach (var evt in newEvents)
{
snapshotToSave.Add(new UntisSnapshotEntry
{
Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(),
Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime,
Summary = evt.Summary, Location = evt.Location, Description = evt.Description,
Status = evt.Status, LastSeenAt = DateTime.UtcNow,
});
if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue;
if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))
{
substitutions.Add(mapping.Kind == SubstitutionKind.Lesson
? BuildCancelledLesson(evt.Date, mapping, evt.Uid)
: BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt."));
continue;
}
if (mapping.Kind == SubstitutionKind.Lesson)
{
if (DeviationReason(evt, mapping) is { } reason)
substitutions.Add(BuildChangedLesson(evt, mapping, reason));
else
externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt
}
else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value)))
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht
// doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses
// Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung
// davon.
substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis."));
}
else
{
externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig
}
}
// Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher
// Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn
// die Aufsicht vorher tatsächlich einmal gesehen wurde).
var snapshotIdsToDelete = new List<Guid>();
foreach (var previous in previousSnapshot)
{
if (newEventsByUid.ContainsKey(previous.Uid)) continue;
if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue;
if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue;
if (mapping.Kind != SubstitutionKind.Supervision) continue;
var vanishedId = BuildVanishedExternalId(previous.Date, mapping);
substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen."));
snapshotIdsToDelete.Add(previous.Id);
}
// Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte
// künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives
// Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen
// VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten
// (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht
// nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht
// stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es
// gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf
// das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht
// Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und
// um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt.
if (newEvents.Count > 0)
{
var maxKnownDate = newEvents.Max(e => e.Date);
var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays);
var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet();
var freeDateSet = freeDates ?? [];
foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson))
{
for (var date = today; date <= horizonEnd; date = date.AddDays(1))
{
if (date.DayOfWeek != mapping.Weekday) continue;
if (freeDateSet.Contains(date)) continue;
if (eventDates.Contains((date, mapping.StartTime)))
{
// War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt
// wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab.
externalIdsToDelete.Add(BuildMissingExternalId(date, mapping));
continue;
}
substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping)));
}
}
}
return new UntisDiffResult
{
SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave,
SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete,
};
}
// Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese
// manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim
// Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die
// Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c;
// Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb
// aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält
// (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt:
// ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit
// Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte
// Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog).
// Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie
// UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens
// akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen.
//
// Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster
// ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der
// genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht
// mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende
// Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den
// gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die
// Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen.
private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping)
{
if (evt.Summary != mapping.Summary)
return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“.";
if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken)))
return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“.";
return null;
}
private static string RemoveWhitespace(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).ToArray());
private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new()
{
Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber,
Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId,
};
private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new()
{
Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber,
GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken,
Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}",
ExternalId = evt.Uid,
};
// Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) -
// deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist.
private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping,
string externalId, string reason) => new()
{
Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod,
Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId,
};
// Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum
// abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls.
private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"vanished-{mapping.Id}-{date:yyyyMMdd}";
// Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus
// Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update
// statt Duplikat), unabhängig vom Snapshot-Stand.
private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) =>
$"missing-{mapping.Id}-{date:yyyyMMdd}";
}
@@ -0,0 +1,241 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
/// <summary>Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster
/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem
/// Stundenplan in der App zuordnet").</summary>
public sealed class UntisWeeklyPattern
{
public DayOfWeek Weekday { get; init; }
public TimeOnly StartTime { get; init; }
public TimeOnly EndTime { get; init; }
public string? Summary { get; init; }
/// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten
/// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]).
public List<string> ClassTokens { get; init; } = [];
public int OccurrenceCount { get; init; }
}
public sealed class UntisSlotMatch
{
public required UntisWeeklyPattern Pattern { get; init; }
/// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) —
/// entspricht CoveredPeriods[0].
public int? PeriodNumber { get; init; }
/// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis
/// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:5009:20 für Stunde 1+2), während der
/// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann.
/// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der
/// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet,
/// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind").
public List<int> CoveredPeriods { get; init; } = [];
/// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause
/// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod.
public int? AfterPeriod { get; init; }
public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0;
public Guid? SuggestedGroupId { get; init; }
public bool IsConfident { get; init; }
}
public sealed class UntisMatchResult
{
public List<UntisSlotMatch> Matches { get; init; } = [];
/// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ
/// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt").
public List<TimetableSlot> UnmatchedTimetableSlots { get; init; } = [];
}
/// <summary>
/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise
/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen
/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei
/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder
/// HTTP-Zugriff, dadurch ohne Mocking testbar.
/// </summary>
public class UntisMatchingService
{
// Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster
// und den tatsächlichen WebUntis-Zeiten.
private const int StartTimeToleranceMinutes = 3;
public UntisMatchResult BuildMatches(List<UntisIcsEvent> events, List<LearningGroup> groups,
List<TimetableSlot> timetableSlots, PeriodScheduleService periodSchedule)
{
var teacherToken = DetectTeacherToken(events);
var patterns = BuildWeeklyPatterns(events, teacherToken);
var matches = new List<UntisSlotMatch>();
foreach (var pattern in patterns)
{
// Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind
// meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in
// einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener,
// grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber.
if (pattern.ClassTokens.Count == 0)
{
matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) });
continue;
}
var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule);
var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule);
var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots);
matches.Add(new UntisSlotMatch
{
Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods,
SuggestedGroupId = groupId, IsConfident = confident,
});
}
var matchedSlotKeys = matches
.Where(m => m.SuggestedGroupId is not null)
.SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : [])
.Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value)))
.ToHashSet();
var unmatchedSlots = timetableSlots
.Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId)))
.ToList();
return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots };
}
/// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed
/// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument).
private static string? DetectTeacherToken(List<UntisIcsEvent> events) =>
events
.Select(e => LastToken(e.Description))
.Where(t => t is not null)
.GroupBy(t => t)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault();
private static string? LastToken(string description)
{
var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0 ? null : parts[^1];
}
private static List<UntisWeeklyPattern> BuildWeeklyPatterns(List<UntisIcsEvent> events, string? teacherToken)
{
var withTokens = events.Select(e => new
{
Event = e,
ClassTokens = ExtractClassTokens(e.Description, teacherToken),
});
var patterns = new List<UntisWeeklyPattern>();
foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime)))
{
var modal = group
.GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens)))
.OrderByDescending(g => g.Count())
.First();
patterns.Add(new UntisWeeklyPattern
{
Weekday = group.Key.Weekday,
StartTime = group.Key.StartTime,
EndTime = group.Key.EndTime,
Summary = modal.Key.Summary,
ClassTokens = modal.Key.ClassTokens.Length == 0
? [] : modal.Key.ClassTokens.Split(';').ToList(),
OccurrenceCount = modal.Count(),
});
}
return patterns;
}
// "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt).
// Trennzeichen zwischen mehreren Klassen wurde nur an einem einzelnen echten Beispiel mit EINER
// Klasse verifiziert (siehe Planungsdokument) - für kombinierte/differenzierte Gruppen ist nicht
// sicher belegt, ob WebUntis ";" oder "," verwendet, deshalb werden beide akzeptiert statt sich
// auf eine Annahme festzulegen.
private static readonly char[] ClassTokenSeparators = [';', ','];
private static List<string> ExtractClassTokens(string description, string? teacherToken)
{
var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken)
? description[..^teacherToken.Length].TrimEnd()
: description;
if (string.IsNullOrWhiteSpace(withoutTeacher)) return [];
return withoutTeacher.Split(ClassTokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule)
{
var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime);
if (exact is not null) return exact.PeriodNumber;
var closest = periodSchedule.Periods
.Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes)))
.Where(t => t.DiffMinutes <= StartTimeToleranceMinutes)
.OrderBy(t => t.DiffMinutes)
.FirstOrDefault();
return closest.Period?.PeriodNumber;
}
// Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer
// einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten
// Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods).
private static List<int> ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule)
{
var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes);
return periodSchedule.Periods
.Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance
&& p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance)
.OrderBy(p => p.PeriodNumber)
.Select(p => p.PeriodNumber)
.ToList();
}
// Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der
// 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine
// Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit.
private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) =>
periodSchedule.Periods
.Where(p => p.End <= startTime)
.OrderByDescending(p => p.End)
.Select(p => (int?)p.PeriodNumber)
.FirstOrDefault() ?? 0;
private static (Guid? GroupId, bool Confident) ResolveGroup(List<string> classTokens, int? periodNumber,
List<LearningGroup> groups, List<TimetableSlot> timetableSlots)
{
if (classTokens.Count == 0) return (null, false);
var candidates = classTokens
.Select(token => ResolveSingleGroup(token, groups))
.Where(g => g is not null)
.Select(g => g!)
.Distinct()
.ToList();
if (candidates.Count == 1) return (candidates[0].Id, true);
if (candidates.Count == 0) return (null, false);
// Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe
// bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat.
if (periodNumber is { } period)
{
var withExistingSlot = candidates
.Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period))
.ToList();
if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true);
}
return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog
}
private static LearningGroup? ResolveSingleGroup(string token, List<LearningGroup> groups)
{
var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact;
var normalizedToken = Normalize(token);
return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken);
}
private static string Normalize(string value) =>
new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray());
}
@@ -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(
+305
View File
@@ -303,6 +303,58 @@ public sealed class RepositoryTests
Assert.Throws<InvalidOperationException>(() => new SeatingPlanRepository(db).Save(plan));
}
[Fact]
public void SeatingPlanRepository_FiltertAusgeblendetePlaetzeAusserhalbDesRasters()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var plan = new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
HiddenSeats =
[
new HiddenSeat { Row = 0, Column = 1 },
new HiddenSeat { Row = 5, Column = 5 }, // außerhalb des 2x2-Rasters
],
};
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
var hidden = Assert.Single(saved.HiddenSeats);
Assert.Equal((0, 1), (hidden.Row, hidden.Column));
}
/// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist wertvoller
/// als das Ausblenden. SeatingPlanTabViewModel verhindert das Belegen ausgeblendeter Plätze
/// bereits in der UI; diese Absicherung greift für Daten, die diesen Weg umgehen (z. B. Sync
/// von einem älteren Client).
[Fact]
public void SeatingPlanRepository_BlendetPlatzWiederEinWennErBelegtWird()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var student = new Student { FirstName = "Anna", LastName = "A" };
db.Students.Insert(student);
db.Memberships.Insert(new GroupMembership { GroupId = group.Id, StudentId = student.Id });
var plan = new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }],
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
new SeatingPlanRepository(db).Save(plan);
var saved = db.SeatingPlans.FindById(plan.Id);
Assert.NotNull(saved);
Assert.Empty(saved.HiddenSeats);
Assert.Equal(student.Id, saved.Assignments.Single().StudentId);
}
[Fact]
public void SeatingPlanRepository_AktualisiertPlanMitFehlendemOptionalenRaum()
{
@@ -688,6 +740,25 @@ public sealed class RepositoryTests
Assert.Equal(2, result[2].LessonNumber);
}
[Fact]
public void LessonRepository_DeleteRaeumtAnhaengeAusDerAttachmentAblageAuf()
{
using var db = NewInMemoryContext();
var repo = new LessonRepository(db);
var lesson = new Lesson
{
UnitId = Guid.NewGuid(), GroupId = Guid.NewGuid(),
Attachments = [new DocumentAttachment { StorageId = "gbu-1", FileName = "gbu.pdf" }],
};
db.Attachments.Upload("gbu-1", "gbu.pdf", new MemoryStream([1, 2, 3]));
repo.Save(lesson);
repo.Delete(lesson.Id);
Assert.Null(db.Lessons.FindById(lesson.Id));
Assert.False(db.Attachments.Exists("gbu-1"));
}
// ── ShorthandCodeRepository ───────────────────────────────────────────────
[Fact]
@@ -883,4 +954,238 @@ public sealed class RepositoryTests
Assert.Single(result);
Assert.Equal("Vertretung 8a", result[0].Description);
}
[Fact]
public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag()
{
using var db = NewInMemoryContext();
var repo = new SubstitutionEntryRepository(db);
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" });
repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 });
var result = repo.GetByExternalId("38818-2004584-2004587");
Assert.NotNull(result);
Assert.Equal(new DateOnly(2026, 3, 12), result!.Date);
Assert.Null(repo.GetByExternalId("unbekannt"));
}
// ── UntisSnapshotRepository / UntisSlotMappingRepository ─────────────────
[Fact]
public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
repo.Save(new UntisSnapshotEntry
{
Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17),
StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", Location = "Medien", Description = "10c HED",
});
var result = repo.GetAll();
Assert.Single(result);
Assert.Equal("38926-2013812-2013815", result[0].Uid);
Assert.Equal("SOL", result[0].Summary);
}
[Fact]
public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag()
{
using var db = NewInMemoryContext();
var repo = new UntisSnapshotRepository(db);
var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" };
repo.Save(entry);
entry.Location = "A1";
entry.Status = "CANCELLED";
repo.Save(entry);
var result = Assert.Single(repo.GetAll());
Assert.Equal("A1", result.Location);
Assert.Equal("CANCELLED", result.Status);
}
[Fact]
public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip()
{
using var db = NewInMemoryContext();
var repo = new UntisSlotMappingRepository(db);
var groupId = Guid.NewGuid();
var mapping = new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50),
Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true,
};
repo.Save(mapping);
Assert.Single(repo.GetAll());
repo.Delete(mapping.Id);
Assert.Empty(repo.GetAll());
}
// ── AnnualPlanEventRepository ────────────────────────────────────────────
[Fact]
public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine()
{
using var db = NewInMemoryContext();
var repo = new AnnualPlanEventRepository(db);
repo.Save(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt",
StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true,
});
repo.Save(new AnnualPlanEvent
{
ExternalId = "spaeter", Title = "Sommerfest",
StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true,
});
var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17));
Assert.Single(result);
Assert.Equal("fahrt", result[0].ExternalId);
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]
public void WorkTaskRepository_GetByGroup_FindetNurAufgabenDieserGruppe()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var groupId = Guid.NewGuid();
repo.Save(new WorkTask { Title = "Klassenaufgabe", GroupId = groupId });
repo.Save(new WorkTask { Title = "Andere Gruppe", GroupId = Guid.NewGuid() });
repo.Save(new WorkTask { Title = "Ohne Gruppe", GroupId = null });
var result = repo.GetByGroup(groupId);
Assert.Single(result);
Assert.Equal("Klassenaufgabe", result[0].Title);
}
[Fact]
public void WorkTaskRepository_SaveUndLoad_RundetPrioritaetUndChecklistItemsRoundTrip()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var studentId = Guid.NewGuid();
var task = new WorkTask
{
Title = "Material einsammeln",
Priority = TaskPriority.High,
ChecklistItems = [new ChecklistItem { Label = "Anna", StudentId = studentId, IsDone = true }],
};
repo.Save(task);
var loaded = repo.GetAll().Single(t => t.Id == task.Id);
Assert.Equal(TaskPriority.High, loaded.Priority);
var item = Assert.Single(loaded.ChecklistItems);
Assert.Equal("Anna", item.Label);
Assert.Equal(studentId, item.StudentId);
Assert.True(item.IsDone);
}
}
+181
View File
@@ -0,0 +1,181 @@
using LehrerApp.Core.Models;
using LehrerApp.Data.Repositories;
using Xunit;
namespace LehrerApp.Data.Tests;
public sealed class TrashTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void GradeRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var student = new Student { FirstName = "Anna", LastName = "A" };
db.Students.Insert(student);
var repo = new GradeRepository(db);
var grade = new Grade
{
StudentId = student.Id, GroupId = group.Id, Category = GradeCategory.Oral,
Value = "2", Date = new DateOnly(2026, 3, 4),
};
repo.Save(grade);
repo.Delete(grade.Id);
Assert.Null(db.Grades.FindById(grade.Id));
var trashed = db.TrashedItems.FindAll().Single();
Assert.Equal(nameof(Grade), trashed.EntityType);
Assert.Equal("Note 2 (04.03.2026)", trashed.Summary);
repo.Restore(trashed.Id);
var restored = db.Grades.FindById(grade.Id);
Assert.NotNull(restored);
Assert.Equal("2", restored!.Value);
Assert.Empty(db.TrashedItems.FindAll());
}
[Fact]
public void GradeRepository_RestoreMitUnbekannterTrashIdTutNichts()
{
using var db = NewInMemoryContext();
var repo = new GradeRepository(db);
repo.Restore(Guid.NewGuid());
Assert.Empty(db.Grades.FindAll());
}
[Fact]
public void WorkTaskRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Elternbrief schreiben", Category = TaskCategory.Admin };
repo.Save(task);
repo.Delete(task.Id);
Assert.Null(db.Tasks.FindById(task.Id));
Assert.Equal("Elternbrief schreiben", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.Tasks.FindById(task.Id));
}
[Fact]
public void TimeEntryRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new TimeEntryRepository(db);
var entry = new TimeEntry { Category = "Korrektur", Date = new DateOnly(2026, 3, 4), DurationMinutes = 45 };
repo.Save(entry);
repo.Delete(entry.Id);
Assert.Null(db.TimeEntries.FindById(entry.Id));
Assert.Equal("45 Min. Korrektur (04.03.2026)", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.TimeEntries.FindById(entry.Id));
}
[Fact]
public void SeatingPlanRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Room = "B204", Rows = 2, Columns = 2 };
repo.Save(plan);
repo.Delete(plan.Id);
Assert.Null(db.SeatingPlans.FindById(plan.Id));
Assert.Equal("Standard (Raum B204)", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.SeatingPlans.FindById(plan.Id));
}
[Fact]
public void SeatingPlanRepository_DeleteOhneRaumNutztNurDenNamenAlsSummary()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a", SchoolYear = "2025/26" };
new GroupRepository(db).Save(group);
var repo = new SeatingPlanRepository(db);
var plan = new SeatingPlan { GroupId = group.Id, Name = "Standard", Rows = 2, Columns = 2 };
repo.Save(plan);
repo.Delete(plan.Id);
Assert.Equal("Standard", db.TrashedItems.FindAll().Single().Summary);
}
[Fact]
public void GradingKeyTemplateRepository_DeleteVerschiebtInDenPapierkorbUndRestoreStelltWiederHer()
{
using var db = NewInMemoryContext();
var repo = new GradingKeyTemplateRepository(db);
var template = new GradingKeyTemplate { Name = "Standard 1-6", GradingSystem = GradingSystem.Grades1To6 };
repo.Save(template);
repo.Delete(template.Id);
Assert.Null(db.GradingKeyTemplates.FindById(template.Id));
Assert.Equal("Standard 1-6", db.TrashedItems.FindAll().Single().Summary);
repo.Restore(db.TrashedItems.FindAll().Single().Id);
Assert.NotNull(db.GradingKeyTemplates.FindById(template.Id));
}
[Fact]
public void TrashRepository_GetAllSortiertNeuesteZuerst()
{
using var db = NewInMemoryContext();
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Älter", DeletedAt = DateTime.UtcNow.AddDays(-2),
});
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Neuer", DeletedAt = DateTime.UtcNow,
});
var all = new TrashRepository(db).GetAll();
Assert.Equal(2, all.Count);
Assert.Equal("Neuer", all[0].Summary);
Assert.Equal("Älter", all[1].Summary);
}
[Fact]
public void TrashRepository_PurgeOlderThanEntferntNurAeltereEintraege()
{
using var db = NewInMemoryContext();
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Alt", DeletedAt = DateTime.UtcNow.AddDays(-40),
});
db.TrashedItems.Insert(new TrashedItem
{
EntityType = nameof(WorkTask), Summary = "Frisch", DeletedAt = DateTime.UtcNow,
});
new TrashRepository(db).PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
var remaining = db.TrashedItems.FindAll().ToList();
Assert.Single(remaining);
Assert.Equal("Frisch", remaining[0].Summary);
}
}
@@ -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.
+50
View File
@@ -66,6 +66,14 @@ public class LiteDbContext : IDisposable
public ILiteCollection<SchoolHoliday> SchoolHolidays => _db.GetCollection<SchoolHoliday>("school_holidays");
public ILiteCollection<SupervisionDuty> SupervisionDuties => _db.GetCollection<SupervisionDuty>("supervision_duties");
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<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");
public void Checkpoint() => _db.Checkpoint();
@@ -182,6 +190,38 @@ public class LiteDbContext : IDisposable
Documentation.Delete(id);
}
// ── Papierkorb (14.3) ────────────────────────────────────────────────────
//
// Absichtlich hier statt in einer eigenen TrashRepository-Konstruktorabhängigkeit: jede
// Fach-Repository hat db (LiteDbContext) bereits im Konstruktor, ein zusätzlicher Papierkorb-
// Parameter wäre nur Boilerplate. Snapshot/Restore sind generisch über den Modelltyp, die
// eigentliche Wiederherstellung ruft aber bewusst die jeweilige Repository.Save-Methode auf
// (nicht direkt die LiteDB-Collection) — nur die kennt Validierung und OnChange-Ereignis für
// ihr Modell.
/// <summary>Legt einen Papierkorb-Eintrag an, BEVOR die aufrufende Repository die Entität aus
/// ihrer eigentlichen Collection löscht.</summary>
internal void MoveToTrash<T>(string entityType, Guid entityId, T entity, string summary) =>
TrashedItems.Insert(new TrashedItem
{
EntityType = entityType,
EntityId = entityId,
Snapshot = System.Text.Json.JsonSerializer.Serialize(entity),
Summary = summary,
});
/// <summary>Liefert die gelöschte Entität aus dem Papierkorb-Eintrag zurück und entfernt den
/// Eintrag - unabhängig davon, ob die aufrufende Repository den Schnappschuss danach
/// erfolgreich speichert (ein fehlgeschlagenes Restore soll nicht endlos wiederholbar sein,
/// derselbe Datensatz landet notfalls einfach nicht wieder im Papierkorb).</summary>
internal T? RestoreFromTrash<T>(Guid trashId)
{
var item = TrashedItems.FindById(trashId);
if (item is null) return default;
TrashedItems.Delete(trashId);
return System.Text.Json.JsonSerializer.Deserialize<T>(item.Snapshot);
}
internal void ExecuteInTransaction(Action action)
{
_db.BeginTrans();
@@ -496,6 +536,16 @@ public class LiteDbContext : IDisposable
SupervisionDuties.EnsureIndex("ux_supervision_weekday_period",
BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true);
SubstitutionEntries.EnsureIndex(x => x.Date);
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();
+173 -4
View File
@@ -108,6 +108,7 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
plan.Room = plan.Room?.Trim() ?? "";
plan.Assignments ??= [];
plan.ColumnGapWidths ??= [];
plan.HiddenSeats ??= [];
if (plan.Name.Length == 0)
throw new ArgumentException("Der Name des Sitzplans darf nicht leer sein.");
if (plan.Rows is < 1 or > 10 || plan.Columns is < 1 or > 10)
@@ -141,6 +142,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (plan.Assignments.Any(a => !memberIds.Contains(a.StudentId)))
throw new InvalidOperationException("Der Sitzplan enthält einen Schüler, der nicht zur Lerngruppe gehört.");
// Ein ausgeblendeter Platz darf nie belegt sein - die Zuordnung eines Schülers ist
// wertvoller als das Ausblenden, deshalb wird hier stillschweigend wieder eingeblendet
// statt die Zuordnung zu verwerfen. Sollte über die UI (SeatingPlanTabViewModel verhindert
// das Belegen ausgeblendeter Plätze) nie vorkommen - reine Absicherung bei fehlerhaften
// oder von einem älteren Client synchronisierten Daten.
var assignedPositions = plan.Assignments.Select(a => (a.Row, a.Column)).ToHashSet();
plan.HiddenSeats = plan.HiddenSeats
.Where(h => h.Row >= 0 && h.Row < plan.Rows && h.Column >= 0 && h.Column < plan.Columns)
.Where(h => !assignedPositions.Contains((h.Row, h.Column)))
.DistinctBy(h => (h.Row, h.Column))
.ToList();
plan.UpdatedAt = DateTime.UtcNow;
db.SeatingPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(SeatingPlan), plan.Id.ToString(), "Save", plan);
@@ -148,11 +161,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
public void Delete(Guid id)
{
if (db.SeatingPlans.FindById(id) is { } plan)
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
if (db.SeatingPlans.FindById(id) is not { } plan) return;
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.MoveToTrash(nameof(SeatingPlan), id, plan,
string.IsNullOrWhiteSpace(plan.Room) ? plan.Name : $"{plan.Name} (Raum {plan.Room})");
db.SeatingPlans.Delete(id);
db.OnChange?.Invoke(nameof(SeatingPlan), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<SeatingPlan>(trashId) is { } plan) Save(plan);
}
}
public class GroupMembershipRepository(LiteDbContext db) : IGroupMembershipRepository
@@ -247,9 +267,16 @@ public class GradingKeyTemplateRepository(LiteDbContext db) : IGradingKeyTemplat
}
public void Delete(Guid id)
{
if (db.GradingKeyTemplates.FindById(id) is { } template)
db.MoveToTrash(nameof(GradingKeyTemplate), id, template, template.Name);
db.GradingKeyTemplates.Delete(id);
db.OnChange?.Invoke(nameof(GradingKeyTemplate), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<GradingKeyTemplate>(trashId) is { } template) Save(template);
}
}
public class GradeRepository(LiteDbContext db) : IGradeRepository
@@ -266,11 +293,18 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
}
public void Delete(Guid id)
{
if (db.Grades.FindById(id) is { } grade)
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
if (db.Grades.FindById(id) is not { } grade) return;
ArchivedGroupWriteGuard.EnsureActive(db, grade.GroupId);
db.MoveToTrash(nameof(Grade), id, grade,
$"Note {grade.Value} ({grade.Date:dd.MM.yyyy})");
db.Grades.Delete(id);
db.OnChange?.Invoke(nameof(Grade), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<Grade>(trashId) is { } grade) Save(grade);
}
}
public class GradingSchemeRepository(LiteDbContext db) : IGradingSchemeRepository
@@ -355,7 +389,10 @@ public class LessonRepository(LiteDbContext db) : ILessonRepository
public void Delete(Guid id)
{
if (db.Lessons.FindById(id) is { } lesson)
{
ArchivedGroupWriteGuard.EnsureActive(db, lesson.GroupId);
foreach (var attachment in lesson.Attachments) db.Attachments.Delete(attachment.StorageId);
}
db.Lessons.Delete(id);
db.OnChange?.Invoke(nameof(Lesson), id.ToString(), "Delete", null);
}
@@ -400,6 +437,8 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public List<WorkTask> GetByGroup(Guid groupId) =>
db.Tasks.Find(t => t.GroupId == groupId).OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t)
{
t.UpdatedAt = DateTime.UtcNow;
@@ -408,9 +447,16 @@ public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
}
public void Delete(Guid id)
{
if (db.Tasks.FindById(id) is { } task)
db.MoveToTrash(nameof(WorkTask), id, task, task.Title);
db.Tasks.Delete(id);
db.OnChange?.Invoke(nameof(WorkTask), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<WorkTask>(trashId) is { } task) Save(task);
}
}
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
@@ -428,9 +474,17 @@ public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
}
public void Delete(Guid id)
{
if (db.TimeEntries.FindById(id) is { } entry)
db.MoveToTrash(nameof(TimeEntry), id, entry,
$"{entry.DurationMinutes} Min. {entry.Category} ({entry.Date:dd.MM.yyyy})");
db.TimeEntries.Delete(id);
db.OnChange?.Invoke(nameof(TimeEntry), id.ToString(), "Delete", null);
}
public void Restore(Guid trashId)
{
if (db.RestoreFromTrash<TimeEntry>(trashId) is { } entry) Save(entry);
}
}
public class ParticipationSessionRepository(LiteDbContext db) : IParticipationSessionRepository
@@ -438,6 +492,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);
@@ -688,6 +743,8 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
public List<SubstitutionEntry> GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) =>
db.SubstitutionEntries.Find(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) =>
db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry)
{
db.SubstitutionEntries.Upsert(entry);
@@ -700,6 +757,103 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR
}
}
// Bewusst kein db.OnChange hier (anders als sonst überall): UntisSnapshotEntry ist eine rein
// lokale, hochfrequente Abgleich-Zwischenablage (jeder Poll aktualisiert alle Zeilen), deren
// Sync nur Rauschen erzeugen würde. UntisSlotMapping ist an die pro Gerät hinterlegte
// WebUntis-URL gebunden (WebUntisSettingsService, wie SyncSettingsService nicht synchronisiert)
// und deshalb ebenfalls sinnvollerweise lokal — jedes Gerät bestätigt seine Zuordnung einmal
// selbst über den Review-Dialog.
public class UntisSnapshotRepository(LiteDbContext db) : IUntisSnapshotRepository
{
public List<UntisSnapshotEntry> GetAll() => db.UntisSnapshotEntries.FindAll().ToList();
public void Save(UntisSnapshotEntry entry) => db.UntisSnapshotEntries.Upsert(entry);
public void Delete(Guid id) => db.UntisSnapshotEntries.Delete(id);
}
public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRepository
{
public List<UntisSlotMapping> GetAll() => db.UntisSlotMappings.FindAll().ToList();
public void Save(UntisSlotMapping mapping) => db.UntisSlotMappings.Upsert(mapping);
public void Delete(Guid id) => db.UntisSlotMappings.Delete(id);
}
// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines
// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen.
public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository
{
public List<AnnualPlanEvent> GetAll() =>
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from)
.OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry);
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 CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository
{
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
@@ -751,3 +905,18 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
});
}
}
/// <summary>Papierkorb (14.3) — bewusst nicht sync-fähig: TrashedItem wird nie über
/// db.OnChange gemeldet, bleibt also rein lokal auf dem Gerät, auf dem gelöscht wurde. Ein
/// "Fehlklick sofort rückgängig machen"-Werkzeug, kein geräteübergreifendes Archiv.</summary>
public class TrashRepository(LiteDbContext db) : ITrashRepository
{
public List<TrashedItem> GetAll() =>
db.TrashedItems.FindAll().OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc)
{
foreach (var item in db.TrashedItems.Find(t => t.DeletedAt < cutoffUtc).ToList())
db.TrashedItems.Delete(item.Id);
}
}
@@ -0,0 +1,46 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSettingsServiceTests
{
private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern";
private static string TempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen()
{
var path = TempPath();
var service = new AnnualPlanSettingsService(path);
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json"));
var reloaded = new AnnualPlanSettingsService(path);
Assert.DoesNotContain("token=intern", raw);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
Assert.True(reloaded.Enabled);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport()
{
var service = new AnnualPlanSettingsService(TempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.False(service.Enabled);
Assert.Null(service.GetIcalUrl());
}
}
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AnnualPlanSyncServiceTests
{
private static string Calendar(params string[] events) =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n";
private static string Event(string uid, string title, string date = "20260823") =>
$"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n";
private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) =>
new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events);
[Fact]
public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine()
{
var events = new FakeAnnualPlanEvents();
using var service = Build(events);
var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var originalId = events.GetByExternalId("1")!.Id;
var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe")));
var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824")));
Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted));
Assert.Equal(0, unchanged.ChangeCount);
Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted));
Assert.Equal(originalId, events.GetByExternalId("1")!.Id);
Assert.Null(events.GetByExternalId("2"));
}
[Fact]
public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "bestand", Title = "Bestehend",
StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var broken = Calendar(Event("neu", "Neu"),
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
Assert.Throws<FormatException>(() => service.ProcessIcsText(broken));
var remaining = Assert.Single(events.GetAll());
Assert.Equal("bestand", remaining.ExternalId);
}
[Fact]
public void Clear_EntferntDenLokalenFeedCache()
{
var events = new FakeAnnualPlanEvents();
events.Add(new AnnualPlanEvent
{
ExternalId = "1", StartDate = new DateOnly(2026, 8, 23),
EndDate = new DateOnly(2026, 8, 23), IsAllDay = true,
});
using var service = Build(events);
var notified = false;
service.DataChanged += () => notified = true;
service.Clear();
Assert.Empty(events.GetAll());
Assert.True(notified);
}
}
@@ -0,0 +1,62 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class AppearanceSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Load_OhneVorhandeneDatei_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, new AppearanceSettingsService(BuildTempPath()).Load());
[Theory]
[InlineData(AppTheme.Light)]
[InlineData(AppTheme.Dark)]
[InlineData(AppTheme.System)]
public void SaveUndLoad_PersistiertUeberNeueInstanz(AppTheme theme)
{
var path = BuildTempPath();
new AppearanceSettingsService(path).Save(theme);
var reloaded = new AppearanceSettingsService(path).Load();
Assert.Equal(theme, reloaded);
}
[Fact]
public void Load_BeschaedigteDatei_FaelltAufSystemvorgabeZurueck()
{
var path = BuildTempPath();
File.WriteAllText(Path.Combine(path, "appearancesettings.json"), "{ kein json");
Assert.Equal(AppTheme.System, new AppearanceSettingsService(path).Load());
}
}
public sealed class AppThemeDisplayTests
{
[Theory]
[InlineData(AppTheme.System, "Systemvorgabe")]
[InlineData(AppTheme.Light, "Hell")]
[InlineData(AppTheme.Dark, "Dunkel")]
public void Label_LiefertDeutscheBeschriftung(AppTheme theme, string expected) =>
Assert.Equal(expected, AppThemeDisplay.Label(theme));
[Fact]
public void FromLabel_UndLabel_SindZueinanderInvers()
{
foreach (var theme in Enum.GetValues<AppTheme>())
Assert.Equal(theme, AppThemeDisplay.FromLabel(AppThemeDisplay.Label(theme)));
}
[Fact]
public void FromLabel_UnbekanntesLabel_LiefertSystemvorgabe() =>
Assert.Equal(AppTheme.System, AppThemeDisplay.FromLabel("Nonsens"));
}
@@ -0,0 +1,587 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.ClassTeacher;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ClassTeacherViewModelsTests
{
[Fact]
public void GroupByStudentAndDay_FasstFehlstundenProSchuelerUndTagZusammen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", "Absent",
"Krank", 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "PAN", "Kunst", null,
"Krank", 2, "24.08.26", true, null, 5, "nicht entsch.", true),
new UntisClassAbsenceEntryDto("Andere Schuelerin", 2, "6a", 20260824, 1, 20, "BEN", "Deu",
"Verspätung", null, 3, "24.08.26", false, "Zug verpasst", 1, "entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
var max = rows[1];
Assert.Equal("Muster Max", max.StudentName);
Assert.Equal(2, max.TotalAbsentPeriods);
Assert.Equal(90, max.TotalAbsentMinutes);
Assert.Equal(["Che", "Kunst"], max.Subjects);
Assert.Equal([3, 5], max.PeriodNumbers.Order());
Assert.Equal(["nicht entsch."], max.Statuses);
Assert.Equal(["Absent"], max.AbsenceReasons);
Assert.Equal("Krank", max.Note);
Assert.Null(max.ExcuseNote);
Assert.True(max.CountsAsFullDay);
var andere = rows[0];
Assert.Equal("Andere Schuelerin", andere.StudentName);
Assert.Equal(1, andere.TotalAbsentPeriods);
Assert.Equal(20, andere.TotalAbsentMinutes);
Assert.Equal(["Deu"], andere.Subjects);
Assert.Null(andere.Note);
Assert.Equal("Zug verpasst", andere.ExcuseNote);
Assert.False(andere.CountsAsFullDay);
}
[Fact]
public void GroupByStudentAndDay_TrenntGleichenNamenAnUnterschiedlichenTagen()
{
var entries = new[]
{
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260824, 1, 45, "HED", "Che", null,
null, 1, "24.08.26", true, null, 3, "nicht entsch.", false),
new UntisClassAbsenceEntryDto("Muster Max", 1, "6a", 20260825, 1, 45, "HED", "Che", null,
null, 2, "25.08.26", true, null, 3, "nicht entsch.", false),
};
var rows = ClassAbsenceDaySummaryRow.GroupByStudentAndDay(entries);
Assert.Equal(2, rows.Count);
Assert.Equal(new DateOnly(2026, 8, 25), rows[0].Date);
Assert.Equal(new DateOnly(2026, 8, 24), rows[1].Date);
}
[Fact]
public void RosterBuild_OrdnetFehlzeitPerExternKeyUndKlassenbuchPerNameZu()
{
var students = new[]
{
Student(1001, "Ada Müller"),
Student(1002, "Ben Schmidt"),
Student(1003, "Cem Yilmaz"),
};
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Ada Müller", 1001, 2, 90,
["Che"], [3, 4], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Ben Schmidt", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
Assert.Equal(3, roster.Count);
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.True(ada.HasAbsenceToday);
Assert.Contains("nicht entsch.", ada.AbsenceTooltip);
Assert.False(ada.HasRecentClassRegisterEntry);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.False(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
var cem = Assert.Single(roster, r => r.StudentName == "Cem Yilmaz");
Assert.False(cem.HasAbsenceToday);
Assert.False(cem.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_ErkenntNamenAuchInVertauschterReihenfolge()
{
// WebUntis liefert Namen im Schülerreport als "Vorname Nachname", in Fehlzeiten-/
// Klassenbuchberichten aber typischerweise als "Nachname Vorname" - der Namensabgleich
// muss das auch ohne ExternKey erkennen (Regressionstest für genau diesen Bug).
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Schmidt Ben", null, 1, 45,
["Che"], [3], ["nicht entsch."], ["Absent"], null, null, false),
};
var recentClassRegister = new[]
{
new UntisForeignClassRegisterEventDto("6a", 20260824, "Deu", "Schmidt Ben", "mueller",
"Vergessen", "Organisation", "Buch vergessen"),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, recentClassRegister);
var ben = Assert.Single(roster);
Assert.True(ben.HasAbsenceToday);
Assert.True(ben.HasRecentClassRegisterEntry);
}
[Fact]
public void RosterBuild_BeschreibtVerspaetungNichtAlsNullFehlstunden()
{
var students = new[] { Student(1001, "Ada Müller") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 0, 20,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, todayAbsences, [],
new DateOnly(2026, 8, 26)));
Assert.True(row.IsLate);
Assert.True(row.IsUnexcused);
Assert.Equal("20 Min. verspätet · Unentschuldigt", row.StatusText);
Assert.Equal(ClassTeacherStatusKind.Danger, row.StatusKind);
}
[Fact]
public void RosterBuild_PriorisiertHandlungsbedarfVorUnauffaelligenSchuelern()
{
var students = new[]
{
Student(1001, "Zora Unauffällig"),
Student(1002, "Ada Fehlzeit"),
Student(1003, "Ben Klassenbuch"),
};
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Fehlzeit Ada", 1002, 2, 90,
["Deu"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
};
var register = new[]
{
new UntisForeignClassRegisterEventDto("10c", 20260825, "Deu", "Klassenbuch Ben", "test",
"Fehlende HA", "Negativ", "Hausaufgaben fehlen"),
};
var rows = ClassTeacherRosterRow.Build(students, absences, register, new DateOnly(2026, 8, 26));
Assert.Equal(["Ada Fehlzeit", "Ben Klassenbuch", "Zora Unauffällig"],
rows.Select(r => r.StudentName));
}
[Fact]
public void RosterBuild_BerechnetFehlquoteSeitSchuljahresbeginn()
{
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 6, 270,
["Che"], [1, 2, 3, 4, 5, 6], ["nicht entsch."], ["Absent"], null, null, true),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 20,
termStart: new DateOnly(2026, 8, 1)));
Assert.True(row.HasYearSummary);
Assert.Equal(2, row.YearAbsenceDayCount);
Assert.Equal(1, row.YearUnexcusedDayCount);
Assert.Equal(10, row.YearAbsenceRatePercent);
Assert.Contains("10 %", row.YearSummaryLabel);
Assert.Contains("2 von 20", row.YearSummaryTooltip);
Assert.Contains("seit 01.08.", row.YearSummaryTooltip);
Assert.Contains("1 unentschuldigt", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_ZeigtAbgezogeneFerientageImTooltip()
{
// Nutzer-Nachfrage: die Herkunft des Nenners soll sich ohne Blick in die WebUntis-Ferienliste
// direkt in der App nachvollziehen lassen, nachdem er zeitweise Ferientage mitzählte.
var students = new[] { Student(1001, "Ada Müller") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 17), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [],
new DateOnly(2026, 8, 26), yearAbsences, schoolDaysElapsed: 14, holidayWeekdaysExcluded: 7,
termStart: new DateOnly(2026, 8, 1)));
Assert.Contains("7 Ferientage abgezogen", row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OhneJahresdatenZeigtKeineFehlquote()
{
var students = new[] { Student(1001, "Ada Müller") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.False(row.HasYearSummary);
Assert.Equal("", row.YearSummaryLabel);
Assert.Null(row.YearSummaryTooltip);
}
[Fact]
public void RosterBuild_OrdnetJahresfehlzeitenAuchOhneExternKeyUeberNamenZu()
{
var students = new[] { Student(externKey: null, displayName: "Ben Schmidt") };
var yearAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 10), "Schmidt Ben", null, 2, 90,
["Che"], [1, 2], ["entsch."], ["Krank"], null, null, false),
};
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26),
yearAbsences, schoolDaysElapsed: 10));
Assert.Equal(1, row.YearAbsenceDayCount);
}
[Fact]
public void TrendDay_AnteileSummierenSichZurGesamtquote()
{
var day = new ClassTeacherTrendDay("Mo", 5, 2, 1, 2, 0.4d, 0.2d, 0.4d);
Assert.Equal(1.0d, day.TotalFraction, precision: 10);
}
[Fact]
public void RosterBuild_StelltGefahrUndWarnungMitSymbolVoran()
{
var students = new[] { Student(1001, "Ada Müller"), Student(1002, "Ben Schmidt") };
var todayAbsences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Müller Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 26), "Schmidt Ben", 1002, 2, 90,
["Che"], [1, 2], ["entsch."], ["Absent"], null, null, false),
};
var roster = ClassTeacherRosterRow.Build(students, todayAbsences, [], new DateOnly(2026, 8, 26));
var ada = Assert.Single(roster, r => r.StudentName == "Ada Müller");
Assert.Equal(ClassTeacherStatusKind.Danger, ada.StatusKind);
Assert.StartsWith("✕ ", ada.StatusTextWithGlyph);
var ben = Assert.Single(roster, r => r.StudentName == "Ben Schmidt");
Assert.Equal(ClassTeacherStatusKind.Warning, ben.StatusKind);
Assert.StartsWith("△ ", ben.StatusTextWithGlyph);
}
[Fact]
public void RosterBuild_LaesstUnauffaelligenStatusOhneSymbol()
{
var students = new[] { Student(1001, "Zora Unauffällig") };
var row = Assert.Single(ClassTeacherRosterRow.Build(students, [], [], new DateOnly(2026, 8, 26)));
Assert.Equal(row.StatusText, row.StatusTextWithGlyph);
}
// ── Nenner der Jahresfehlquote: echter WebUntis-Ferienkalender statt grober Werktagszählung ──
[Fact]
public void CountSchoolWeekdays_SchliesstFerientageAus()
{
// 1.-31.08.2026 hat 21 Werktage; die (fiktiven) Sommerferien bis 11.08. nehmen davon 7
// Werktage weg (Mo 03. - Fr 07., Mo 10. - Di 11.) - Nutzer-Feedback: Schüler, die seit
// Unterrichtsbeginn jeden Tag fehlten, kamen wegen dieser mitgezählten Ferienzeit nur auf
// ~57 % statt ~100 % Fehlquote.
var holidays = new[] { new CachedUntisHoliday("Sommerferien", new DateOnly(2026, 7, 1), new DateOnly(2026, 8, 11)) };
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), holidays);
Assert.Equal(14, schoolDays);
}
[Fact]
public void CountSchoolWeekdays_OhneFerienVerhaeltSichWieReineWerktagszaehlung()
{
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), []);
Assert.Equal(21, schoolDays);
}
[Fact]
public void EstimateTermStart_NutztFruehestenFehlzeitEintragDerGanzenKlasse()
{
// Realer Befund (Nutzer-Feedback): WebUntis' getHolidays liefert für Bremen nie einen
// Sommerferien-Eintrag (über 11 Jahre Kontohistorie geprüft, kein einziger Juli-/August-
// Zeitraum dabei) - vermutlich weil die Sommerferien WebUntis-intern zwischen zwei
// Schuljahres-Datensätzen liegen (1.8./31.7.-Grenze), nicht "in" einem davon. Für den
// Schuljahresbeginn bleibt deshalb weiterhin der früheste Fehlzeiten-Eintrag der Klasse
// nötig statt CountSchoolWeekdays allein.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 13), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 19), "Andere Ada", 1002, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart(absences, new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 13), termStart);
}
[Fact]
public void EstimateTermStart_FaelltOhneFehlzeitenAufSchuljahresbeginnZurueck()
{
var termStart = ClassTeacherOverviewViewModel.EstimateTermStart([], new DateOnly(2026, 8, 1));
Assert.Equal(new DateOnly(2026, 8, 1), termStart);
}
[Fact]
public void CountSchoolWeekdays_AbEchtemTerminstartOhneSommerferienEintragStimmtMitBeobachtungUeberein()
{
// Regressionstest für den konkret gemeldeten Fall: 13 tatsächliche Schultage seit
// Unterrichtsbeginn (13.08.2026, ein Donnerstag) bis 31.08.2026, keine WebUntis-Ferien im
// Bereich (die nächste, "Herbstferien", liegt erst im Oktober) - Kombination aus
// EstimateTermStart (Sommerferien-Lücke) und CountSchoolWeekdays (übrige Ferien) muss auf
// die vom Nutzer nachgezählte Zahl kommen, nicht auf die volle Werktagszahl ab 1.8. (21).
var termStart = new DateOnly(2026, 8, 13);
var noHolidaysInRange = Array.Empty<CachedUntisHoliday>();
var schoolDays = ClassTeacherOverviewViewModel.CountSchoolWeekdays(
termStart, new DateOnly(2026, 8, 31), noHolidaysInRange);
Assert.Equal(13, schoolDays);
}
// ── Offene Entschuldigungen (Feature-Idee 2) ─────────────────────────────
[Fact]
public void OpenExcuseBuild_SortiertNachTagenOffenAbsteigendUndIgnoriertEntschuldigte()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 20), "Alt Ada", 1001, 2, 90,
["Che"], [1, 2], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Entschuldigt Cem", 1003, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
// StudentName kommt aus StudentDisplayName (WebUntis liefert "Nachname Vorname",
// hier für die Anzeige auf "Vorname Nachname" gedreht - siehe ClassAbsenceDaySummaryRow).
Assert.Equal(["Ada Alt", "Ben Neu"], rows.Select(r => r.StudentName));
Assert.Equal(6, rows[0].DaysOpen);
Assert.True(rows[0].IsOverdue);
Assert.Equal(1, rows[1].DaysOpen);
Assert.False(rows[1].IsOverdue);
}
[Fact]
public void OpenExcuseBuild_IgnoriertUnentschuldigteVerspaetungen()
{
// Verspätungen sind i.d.R. nicht entschuldigungsfähig - "unentschuldigt" darf hier keine
// Erinnerung an eine fehlende Entschuldigung auslösen (Nutzer-Feedback).
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Neu Ben", 1002, 0, 15,
["Deu"], [1], ["nicht entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1003, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var rows = ClassTeacherOpenExcuseRow.Build(absences, new DateOnly(2026, 8, 26));
Assert.Equal(["Cem Fehlt"], rows.Select(r => r.StudentName));
}
// ── Verspätungsmuster: Elterngespräch-Eskalation (Nutzer-Feedback) ───────
[Fact]
public void DetectLatePatterns_EskaliertBeiHoherJahressummeMitWiedervorlageOption()
{
var absences = Enumerable.Range(0, 5)
.Select(i => new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 3 + i), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false))
.ToArray();
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 31));
var notice = Assert.Single(notices);
Assert.Equal("Timo Spät", notice.StudentName);
Assert.Contains("5 Verspätungen seit Schuljahresbeginn", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.True(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_KurzfristigesClusterBleibtHinweisOhneAktion()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Spaet Timo", 1001, 0, 10,
["Deu"], [1], ["entsch."], ["Verspätung"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Spaet Timo")] = "Timo Spät" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Equal("2-mal verspätet in 7 Tagen", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Warning, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
[Fact]
public void DetectLatePatterns_UnentschuldigteFehltageGehenVorVerspaetungseskalation()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 25), "Fehlt Cem", 1001, 1, 45,
["Deu"], [1], ["nicht entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Fehlt Cem")] = "Cem Fehlt" };
var notices = ClassTeacherOverviewViewModel.DetectLatePatterns(
absences, names, sevenDayStart: new DateOnly(2026, 8, 20));
var notice = Assert.Single(notices);
Assert.Contains("unentschuldigte Fehltage", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Danger, notice.Kind);
Assert.False(notice.CanCreateReminder);
}
// ── Aggregierte Klassenbuch-Kategorien (Feature-Idee 5) ──────────────────
[Fact]
public void CategoryAggregateBuild_ZaehltProKategorieUndNenntTopSchueler()
{
var entries = new[]
{
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 20), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 21), "Deu", "Schmidt Ben", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 22), "Che", "Müller Ada", "test",
"Hausaufgaben fehlen", "Negativ", "HA fehlt"),
new ClassTeacherClassRegisterRow(new DateOnly(2026, 8, 23), "Che", "Müller Ada", "test",
"Lob", "Positiv", "Gute Mitarbeit"),
};
var rows = ClassTeacherCategoryAggregateRow.Build(entries);
Assert.Equal(["Hausaufgaben fehlen", "Lob"], rows.Select(r => r.CategoryName));
var homework = rows[0];
Assert.Equal(3, homework.Count);
Assert.Contains("2× Ben Schmidt", homework.TopStudentsLabel);
Assert.Contains("1× Ada Müller", homework.TopStudentsLabel);
}
// ── Erweiterte Mustererkennung: Wochentags-Häufung (Feature-Idee 4) ──────
[Fact]
public void DetectWeekdayPatterns_ErkenntDeutlicheHaeufungAnEinemWochentag()
{
// 24.08./31.08.2026 und 07.09.2026 sind allesamt Montage.
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string>());
var notice = Assert.Single(notices);
Assert.Equal("Mona Muster", notice.StudentName);
Assert.Contains("Montag", notice.Message);
Assert.Equal(ClassTeacherStatusKind.Info, notice.Kind);
}
[Fact]
public void DetectWeekdayPatterns_IgnoriertZuKleineStichprobe()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, new Dictionary<string, string>(), new HashSet<string>());
Assert.Empty(notices);
}
[Fact]
public void DetectWeekdayPatterns_LaesstBereitsGemeldeteSchuelerAus()
{
var absences = new[]
{
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 24), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 8, 31), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
new ClassAbsenceDaySummaryRow(new DateOnly(2026, 9, 7), "Muster Mona", 1001, 1, 45,
["Deu"], [1], ["entsch."], ["Absent"], null, null, false),
};
var names = new Dictionary<string, string> { [UntisNameMatching.NameKey("Muster Mona")] = "Mona Muster" };
var notices = ClassTeacherOverviewViewModel.DetectWeekdayPatterns(
absences, names, new HashSet<string> { "Mona Muster" });
Assert.Empty(notices);
}
// ── Noten-/Mitarbeit-Brücke: Namensabgleich (Feature-Idee 7) ─────────────
[Fact]
public void MatchStudent_FindetUeberVorUndNachnameTrotzKommaInFullName()
{
// Student.FullName liefert "Nachname, Vorname" (mit Komma) - ein Abgleich darüber würde
// UntisNameMatching.NameKey verfälschen ("Müller," bliebe eigenes Wort). MatchStudent
// muss deshalb FirstName/LastName getrennt verwenden, nicht FullName.
var students = new List<Student>
{
new() { FirstName = "Ada", LastName = "Müller" },
new() { FirstName = "Ben", LastName = "Schmidt" },
};
var match = ClassTeacherOverviewViewModel.MatchStudent("Müller Ada", students);
Assert.NotNull(match);
Assert.Equal("Ada", match!.FirstName);
}
[Fact]
public void MatchStudent_LiefertNullOhneTreffer()
{
var students = new List<Student> { new() { FirstName = "Ada", LastName = "Müller" } };
Assert.Null(ClassTeacherOverviewViewModel.MatchStudent("Unbekannt Xyz", students));
}
private static UntisStudentRosterCacheEntry Student(int? externKey, string displayName) =>
new() { ClassName = "6a", ExternKey = externKey, DisplayName = displayName };
}
@@ -1,7 +1,6 @@
using System.IO.Compression;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Templating;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -9,16 +8,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 +21,63 @@ 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 CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
private LetterTemplateService ServiceWithTemplate(params string[] tags)
private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions)
{
var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx");
using (var archive = ZipFile.Open(source, ZipArchiveMode.Create))
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 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>");
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;
}
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,15 +1,33 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using System.Globalization;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für 9.1 (heutige Stunden mit Uhrzeit und Raum im Dashboard) und 9.2 (Absprung von einer
/// Stunde). Deckt nur die neue Zeit-/Raum-Auflösung ab, nicht die übrigen, unveränderten
/// Dashboard-Bausteine (Kalender, offene Aufgaben, Fehlzeiten-Warnungen, ...).
/// Stunde), außerdem die Kalender-Sitzungsanzeige (3.3.4) und die Mindeststichprobe der
/// Fehlzeiten-Warnung (5.2.3). Deckt nicht die übrigen, unveränderten Dashboard-Bausteine
/// (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.ExcusesCard.EffectiveIsVisible);
Assert.False(vm.CorrectionsCard.EffectiveIsVisible);
Assert.False(vm.AlertsCard.EffectiveIsVisible);
Assert.Equal("0 offene Punkte", vm.AttentionSummary);
Assert.True(vm.TodayCard.EffectiveIsVisible);
Assert.True(vm.CalendarCard.EffectiveIsVisible);
}
private static PeriodScheduleService NewPeriodSchedule()
{
var tempPath = System.IO.Path.Combine(
@@ -40,20 +58,198 @@ public sealed class DashboardViewModelTests
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null)
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = 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(), new FakeSessions([]), new FakeEntries(),
tasks ?? new FakeWorkTasks(), sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
students ?? new FakeStudents([]), documentation ?? new FakeDocumentation(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
substitutions ?? new FakeSubstitutionEntries());
substitutions ?? new FakeSubstitutionEntries(), timeEntries ?? new FakeTimeEntries(), annualPlanEvents);
}
/// 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(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
Assert.True(vm.MissingTeachingTimeCard.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(vm.MissingTeachingTimeEntries, i => i.Date == 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(vm.MissingTeachingTimeEntries, i => i.Date == 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(vm.MissingTeachingTimeEntries, i => i.Date == pastDay);
}
[Fact]
public void MissingTeachingTime_HeuteVorAblaufDerWartezeit_WirdNichtGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert noch keine 30 Minuten zurück.
var futureEnd = TimeOnly.FromDateTime(DateTime.Now).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);
Assert.DoesNotContain(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
public void MissingTeachingTime_HeuteNachAblaufDerWartezeit_WirdGemeldet()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var periodSchedule = NewPeriodSchedule();
// Unterrichtsende liegt garantiert mehr als 30 Minuten zurück.
var pastEnd = TimeOnly.FromDateTime(DateTime.Now).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);
Assert.Contains(vm.MissingTeachingTimeEntries, i => i.Date == today);
}
[Fact]
@@ -223,6 +419,115 @@ 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()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var sessionDate = today.AddDays(3);
var session = new ParticipationSession { GroupId = group.Id, Date = sessionDate, Comment = "Aufsatz" };
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == sessionDate);
Assert.True(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents,
e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz");
}
[Fact]
public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var annualPlan = new FakeAnnualPlanEvents();
annualPlan.Add(new AnnualPlanEvent
{
ExternalId = "fahrt", Title = "Klassenfahrt 6a",
Description = "Aushang beachten", Location = "Jugendherberge",
CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2),
StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false,
});
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" },
annualPlanEvents: annualPlan);
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.True(day.HasAnnualPlanEvent);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson);
var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan);
Assert.Equal("Klassenfahrt 6a", annual.Title);
Assert.Equal("Aushang beachten", annual.Description);
Assert.Contains("13:30", annual.Subtitle);
Assert.Null(annual.GroupId);
}
[Fact]
public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag()
{
// Regressionstest (Nutzer-Feedback): "Sitzung erzeugen" (3.3.1) verknüpft die Sitzung über
// LessonId mit der Stunde — dieselbe Unterrichtsstunde darf im Kalender nicht zusätzlich
// als eigener Sitzungstermin auftauchen.
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var lesson = new Lesson { GroupId = group.Id, Date = today, Topic = "Brechung" };
var session = new ParticipationSession { GroupId = group.Id, Date = today, LessonId = lesson.Id, Comment = "Brechung" };
var vm = BuildVm(group, lesson, sessions: new FakeSessions([session]));
var day = vm.CalendarDays.Single(d => d.Date == today);
Assert.True(day.HasLesson);
Assert.False(day.HasSession);
vm.SelectCalendarDayCommand.Execute(day);
Assert.Single(vm.SelectedDayEvents);
Assert.DoesNotContain(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.ParticipationSession);
}
[Fact]
public void Kalender_EigeneKlasseAmStundenplanTagOhneLessonZeigtDenRingTrotzdem()
{
// Regressionstest (Nutzer-Feedback): der "Meine Klasse"-Ring soll schon laut Stundenplan
// gelten, nicht erst, sobald für den Tag eine Lesson angelegt wurde.
var today = DateOnly.FromDateTime(DateTime.Today);
var tomorrow = today.AddDays(1);
var group = new LearningGroup { Name = "9c", IsOwnClass = true };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = tomorrow.DayOfWeek, PeriodNumber = 3 });
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, slots: slots);
var day = vm.CalendarDays.Single(d => d.Date == tomorrow);
Assert.True(day.IsOwnClassDay);
}
[Fact]
public void DashboardKacheln_SichtbarkeitUndReihenfolgeWerdenGespeichert()
{
@@ -353,4 +658,100 @@ public sealed class DashboardViewModelTests
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void AttendanceWarnings_KleineStichprobeMitHoherQuoteWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 3 erfasste Termine, alle unentschuldigt (100 %) — Stichprobe zu klein für eine Meldung.
for (var i = 0; i < 3; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
}
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today },
students: new FakeStudents([student]), sessions: sessions, entries: entries);
Assert.Empty(vm.AttendanceWarnings);
}
[Fact]
public void AttendanceWarnings_MeldetHoheQuoteAbMindeststichprobe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — genug Stichprobe.
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = group.Id, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
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);
}
[Fact]
public async Task AddTask_SpeichertErgebnisUndAktualisiertOffeneAufgaben()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
vm.OnAddTask = startAsReminder => Task.FromResult<WorkTask?>(
new WorkTask { Title = "Ansage an die Klasse", Kind = startAsReminder ? TaskKind.Reminder : TaskKind.WorkItem });
await vm.AddReminderCommand.ExecuteAsync(null);
Assert.Single(tasks.GetAll());
Assert.Contains(vm.OpenTasks, t => t.Title == "Ansage an die Klasse" && t.IsReminder);
}
[Fact]
public async Task AddTask_OhneDelegatMachtNichts()
{
var group = new LearningGroup { Name = "9c" };
var today = DateOnly.FromDateTime(DateTime.Today);
var tasks = new FakeWorkTasks();
var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today }, tasks: tasks);
await vm.AddTaskCommand.ExecuteAsync(null);
Assert.Empty(tasks.GetAll());
}
[Fact]
public void WeatherWarningItem_BereitetAmtlicheWarnungFuerDashboardAuf()
{
var warning = new WeatherWarning
{
Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
Description = "Es treten Gewitter auf.", Instruction = "Gebäude aufsuchen.",
Severity = "Severe", Onset = new DateTime(2026, 8, 23, 16, 0, 0, DateTimeKind.Utc),
Expires = new DateTime(2026, 8, 23, 18, 0, 0, DateTimeKind.Utc),
};
var item = new DashboardWeatherWarningItem(warning);
Assert.Equal("Amtliche Warnung vor Gewitter", item.Headline);
Assert.Equal("#D32F2F", item.SeverityColor);
Assert.Contains("Uhr", item.PeriodDisplay);
}
}
@@ -137,6 +137,24 @@ public sealed class DocumentationDialogViewModelTests
Assert.Equal("Gut verlaufen", vm.Result.ParentCallData.Impressions);
}
[Fact]
public void Save_SitzplanEntwurf_WirdAbgeschlossenUndBehaeltStundenbezug()
{
var sessionId = Guid.NewGuid();
var existing = new Documentation
{
StudentId = Guid.NewGuid(), Type = DocumentationType.Incident,
Date = DateOnly.FromDateTime(DateTime.Today), Title = "Streit/Konflikt",
IsDraft = true, ParticipationSessionId = sessionId, Tags = ["Streit/Konflikt"],
};
var vm = new DocumentationDialogViewModel(existing.StudentId, existing, new FakeAttachmentStorage());
vm.SaveCommand.Execute(null);
Assert.False(vm.Result!.IsDraft);
Assert.Equal(sessionId, vm.Result.ParticipationSessionId);
}
[Fact]
public void Save_Elternbrief_UebernimmtVersandUndRueckmeldung()
{
@@ -229,6 +247,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);
}
}
+142
View File
@@ -2,7 +2,9 @@ using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Tests;
@@ -29,6 +31,22 @@ 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 WebUntisSettingsService BuildWebUntisSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new WebUntisSettingsService(tempPath);
}
/// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis.
public static AnnualPlanSettingsService BuildAnnualPlanSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AnnualPlanSettingsService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static SyncSettingsService BuildSyncSettingsService()
{
@@ -64,6 +82,31 @@ public static class TestSupport
Directory.CreateDirectory(tempPath);
return new AppLogger(tempPath);
}
/// Für Tests, die nicht speziell den "Schlüssel neu erzeugt"-Warnzustand prüfen.
public static SyncKeyStatus BuildSyncKeyStatus(bool keyWasRegenerated = false) => new(keyWasRegenerated);
/// Neue, leere Fakes je Aufruf - für Tests, die nicht speziell TrashViewModel-Verhalten prüfen.
public static TrashViewModel BuildTrashViewModel() => new(
new FakeTrash(), new FakeGrades(), new FakeWorkTasks(), new FakeTimeEntries(),
new FakeSeatingPlans(), new FakeGradingKeyTemplates());
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static AppearanceSettingsService BuildAppearanceSettingsService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-appearance-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new AppearanceSettingsService(tempPath);
}
/// Eigenes Temp-Verzeichnis je Aufruf, damit Tests sich nicht gegenseitig über dieselbe
/// sync.key stören.
public static SyncKeyRecoveryService BuildSyncKeyRecoveryService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-synckeyrecovery-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SyncKeyRecoveryService(SyncCrypto.GenerateKey(), Path.Combine(tempPath, "sync.key"));
}
}
public class FakeStudents(List<Student> all) : IStudentRepository
@@ -110,12 +153,24 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
_all.Add(plan);
}
public void Delete(Guid id) => _all.RemoveAll(p => p.Id == id);
// Trash-Mechanismus (14.3) lebt real in LiteDbContext.MoveToTrash/RestoreFromTrash - Fakes
// bilden ihn bewusst nicht nach, ViewModel-Tests brauchen das nicht.
public void Restore(Guid trashId) { }
}
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
{
private readonly List<TrashedItem> _all = initial ?? [];
public void Add(TrashedItem item) => _all.Add(item);
public List<TrashedItem> GetAll() => _all.OrderByDescending(t => t.DeletedAt).ToList();
public void PurgeOlderThan(DateTime cutoffUtc) => _all.RemoveAll(t => t.DeletedAt < cutoffUtc);
}
public class FakeSessions(List<ParticipationSession> all) : IParticipationSessionRepository
{
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);
@@ -177,6 +232,7 @@ public class FakeGrades : IGradeRepository
_all.Add(grade);
}
public void Delete(Guid id) => _all.RemoveAll(g => g.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeExams(List<Exam> all) : IExamRepository
@@ -214,6 +270,7 @@ public class FakeGradingKeyTemplates : IGradingKeyTemplateRepository
public GradingKeyTemplate? GetById(Guid id) => _all.FirstOrDefault(t => t.Id == id);
public void Save(GradingKeyTemplate template) { _all.RemoveAll(t => t.Id == template.Id); _all.Add(template); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeSchemes : IGradingSchemeRepository
@@ -382,18 +439,102 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository
public void Add(SubstitutionEntry e) => _all.Add(e);
public List<SubstitutionEntry> GetAll() => _all.ToList();
public List<SubstitutionEntry> GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList();
public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSnapshots : IUntisSnapshotRepository
{
private readonly List<UntisSnapshotEntry> _all = [];
public void Add(UntisSnapshotEntry e) => _all.Add(e);
public List<UntisSnapshotEntry> GetAll() => _all.ToList();
public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
}
public class FakeUntisSlotMappings : IUntisSlotMappingRepository
{
private readonly List<UntisSlotMapping> _all = [];
public void Add(UntisSlotMapping m) => _all.Add(m);
public List<UntisSlotMapping> GetAll() => _all.ToList();
public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); }
public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id);
}
public class FakeAnnualPlanEvents : IAnnualPlanEventRepository
{
private readonly List<AnnualPlanEvent> _all = [];
public void Add(AnnualPlanEvent e) => _all.Add(e);
public List<AnnualPlanEvent> GetAll() => _all.ToList();
public List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to) =>
_all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList();
public AnnualPlanEvent? GetByExternalId(string externalId) =>
_all.FirstOrDefault(e => e.ExternalId == externalId);
public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
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 FakeWorkTasks : IWorkTaskRepository
{
private readonly List<WorkTask> _all = [];
public void Add(WorkTask t) => _all.Add(t);
public List<WorkTask> GetByStatus(WorkTaskStatus status) => _all.Where(t => t.Status == status).ToList();
public List<WorkTask> GetAll() => _all.ToList();
public List<WorkTask> GetByGroup(Guid groupId) => _all.Where(t => t.GroupId == groupId).ToList();
public void Save(WorkTask task) { _all.RemoveAll(t => t.Id == task.Id); _all.Add(task); }
public void Delete(Guid id) => _all.RemoveAll(t => t.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeTimeEntries : ITimeEntryRepository
@@ -406,6 +547,7 @@ public class FakeTimeEntries : ITimeEntryRepository
public List<TimeEntry> GetByTask(Guid taskId) => _all.Where(e => e.TaskId == taskId).ToList();
public void Save(TimeEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); }
public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id);
public void Restore(Guid trashId) { } // siehe FakeSeatingPlans.Restore
}
public class FakeReportGrades : IReportGradeRepository
@@ -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);
}
}
@@ -85,7 +85,7 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
}
[Fact]
public void Save_Doppelstunde_ErzeugtZweiStundenAmSelbenTagMitUnterschiedlicherStundennummer()
public void Save_Doppelstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
@@ -98,9 +98,50 @@ public sealed class GenerateLessonSeriesDialogViewModelTests
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_Dreifachstunde_ErzeugtNurEineStundeVerankertAnDerErstenPeriode()
{
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 4 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 5 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(1, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).ToList();
Assert.Equal([3], created);
}
[Fact]
public void Save_ZweiGetrennteEinzelstundenAmSelbenTagBleibenGetrennt()
{
// Perioden 1 und 3 sind NICHT aufeinanderfolgend (Periode 2 fehlt) — keine Doppelstunde,
// beide bekommen eine eigene Lesson.
var groupId = Guid.NewGuid();
var unitId = Guid.NewGuid();
var from = new DateOnly(2025, 9, 1);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = from.DayOfWeek, PeriodNumber = 3 });
var lessons = new FakeLessons();
var vm = BuildVm(slots, lessons, new FakeSchoolHolidays(), unitId, groupId, from, from);
vm.SaveCommand.Execute(null);
Assert.Equal(2, vm.Result!.Created);
var created = lessons.GetByGroupAndRange(groupId, from, from).Select(l => l.LessonNumber).OrderBy(n => n).ToList();
Assert.Equal([3, 4], created);
Assert.Equal([1, 3], created);
}
[Fact]
@@ -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,11 +18,13 @@ 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 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()),
new PlanningTabViewModel(new FakeUnits(), new FakeLessons(), groups, subjects,
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService()),
new FakeCompetencyDomains(), TestSupport.BuildAiSettingsService(), new FakeSessions([])),
new CompetencyOverviewTabViewModel(new FakeUnits(), exams, new FakeResults(),
new FakeCompetencyDomains(), students, new CompetencyAnalysisService()),
new SeatingPlanTabViewModel(new FakeSeatingPlans(), students, memberships,
@@ -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()
{
@@ -0,0 +1,73 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// <summary>Tests für die direkte, einlagige Lerngruppen-Navigation.</summary>
public sealed class GroupListViewModelTests
{
private static GroupListViewModel BuildVm(params LearningGroup[] groups) =>
new(new FakeGroups([.. groups]), new FakeSubjects([]), new SchoolYearService());
[Fact]
public void Gruppenkarte_OeffnetDirektDieUebersicht()
{
var group = CurrentGroup("9c");
var vm = BuildVm(group);
Guid? openedId = null;
int? openedTab = null;
vm.OnNavigateToDetail = (id, tab) => { openedId = id; openedTab = tab; };
Assert.Single(vm.Groups).OpenCommand.Execute(null);
Assert.Equal(group.Id, openedId);
Assert.Equal(0, openedTab);
}
[Fact]
public async Task Verwaltungsmenue_BearbeitetDieGewaehlteKarteOhneZwischenauswahl()
{
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);
await secondItem.EditCommand.ExecuteAsync(null);
Assert.Equal(second.Id, editedId);
}
[Fact]
public void Suche_FiltertWeiterhinNachGruppenname()
{
var vm = BuildVm(CurrentGroup("9 Chemie"), CurrentGroup("10 Mathematik"));
vm.SearchText = "Chemie";
Assert.Equal("9 Chemie", Assert.Single(vm.Groups).Name);
}
[Fact]
public void Archivieren_WirktAufDieKarteUndEntferntSieAusDerAktivenListe()
{
var group = CurrentGroup("9c");
var vm = BuildVm(group);
var item = Assert.Single(vm.Groups);
item.ToggleArchiveCommand.Execute(null);
Assert.False(group.IsActive);
Assert.Empty(vm.Groups);
}
private static LearningGroup CurrentGroup(string name) => new()
{
Name = name,
SchoolYear = new SchoolYearService().CurrentSchoolYear(),
IsActive = true,
};
}
@@ -0,0 +1,451 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für die Kurs-Übersicht: sie liest bewusst keine eigenen Daten, sondern zieht nur
/// zusammen, was Planung/Klausuren/Mitarbeit/Dokumentation ohnehin schon verwalten.
public sealed class GroupOverviewViewModelTests
{
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,
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(), 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(
List<Lesson>? lessons = null, List<Exam>? exams = null)
{
var groupId = Guid.NewGuid();
var lessonsRepo = new FakeLessons();
foreach (var l in lessons ?? []) lessonsRepo.Add(l);
var examsRepo = new FakeExams(exams ?? []);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var students = new FakeStudents([]);
var vm = NewVm(lessonsRepo, examsRepo, sessions, entries, students);
vm.Initialize(groupId, "Testkurs");
return (vm, lessonsRepo, examsRepo, sessions, entries, students, groupId);
}
[Fact]
public void NaechsteStunde_FindetDieNaechsteGeplanteStundeInnerhalbDesZeitfensters()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(10), Topic = "Später", Status = LessonStatus.Planned });
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(3), Topic = "Brechung", Status = LessonStatus.Planned });
lessons.Add(new Lesson { GroupId = groupId, Date = today.AddDays(1), Topic = "Schon vorbei", Status = LessonStatus.Conducted });
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasNextLesson);
Assert.Contains("Brechung", vm.NextLessonLabel);
}
[Fact]
public void NaechsteStunde_OhneGeplanteStundeIstNichtVorhanden()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasNextLesson);
}
[Fact]
public void NaechsteKlausur_FindetFruehesteKuenftigeKlausur()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var exams = new FakeExams([
new Exam { GroupId = groupId, Date = today.AddDays(20), Title = "Später" },
new Exam { GroupId = groupId, Date = today.AddDays(5), Title = "Klassenarbeit 2" },
new Exam { GroupId = groupId, Date = today.AddDays(-3), Title = "Vergangen" },
]);
var vm = NewVm(exams: exams);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasNextExam);
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()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.Contains("Noch keine Mitarbeitssitzung", vm.ParticipationHintLabel);
Assert.False(vm.ParticipationHintIsStale);
}
[Fact]
public void Mitarbeit_LetzteSitzungVorMehrAls14TagenGiltAlsVeraltet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-20) }]);
var vm = NewVm(sessions: sessions);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.ParticipationHintIsStale);
}
[Fact]
public void Mitarbeit_LetzteSitzungKuerzlichGiltNichtAlsVeraltet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var sessions = new FakeSessions([new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) }]);
var vm = NewVm(sessions: sessions);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.ParticipationHintIsStale);
}
[Fact]
public void OffeneHausaufgabenKontrolle_ZeigtUnkontrollierteHausaufgabeDerLetztenStunde()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
HomeworkChecked = false, HomeworkCheckDismissed = false,
});
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasOpenHomeworkCheck);
Assert.Contains("S. 42 Nr. 3", vm.OpenHomeworkCheckLabel);
}
[Fact]
public void OffeneHausaufgabenKontrolle_VerschwindetSobaldKontrolliert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = groupId, Date = today.AddDays(-2), Homework = "S. 42 Nr. 3",
HomeworkChecked = true,
});
var vm = NewVm(lessons: lessons);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasOpenHomeworkCheck);
}
[Fact]
public void OffeneEntschuldigungen_ListetOffenePendenteEntschuldigungenUndErlaubtAufloesen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-2) };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
var entry = new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.ExcusePending };
entries.Add(entry);
var students = new FakeStudents([student]);
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.OpenExcuses);
Assert.Equal(student.FullName, item.StudentName);
item.MarkExcusedCommand.Execute(null);
Assert.Empty(vm.OpenExcuses);
Assert.Equal(AttendanceStatus.Excused, entries.GetBySessionAndStudent(session.Id, student.Id)!.Attendance);
}
[Fact]
public void OffeneDokumentationen_ZaehltNurEntwuerfeDieserGruppeOderOhneGruppe()
{
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var documentation = new FakeDocumentation();
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = true, Title = "Beobachtung" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = null, IsDraft = true, Title = "Ohne Gruppe" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = otherGroupId, IsDraft = true, Title = "Anderer Kurs" });
documentation.Add(new Documentation { StudentId = student.Id, GroupId = groupId, IsDraft = false, Title = "Fertig" });
var vm = NewVm(students: students, documentation: documentation);
vm.Initialize(groupId, "Testkurs");
Assert.Equal(2, vm.DraftDocumentationCount);
}
[Fact]
public void AuffaelligeFehlzeiten_WirdErstAbMindeststichprobeGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// Nur 3 erfasste Termine, davon 3 unentschuldigt (100 %) — trotz hoher Quote noch keine
// Meldung, weil die Stichprobe zu klein ist (Schuljahresbeginn-Problem).
for (var i = 0; i < 3; i++)
{
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
sessions.Save(session);
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = AttendanceStatus.Unexcused });
}
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
Assert.False(vm.HasAttendanceNotices);
}
[Fact]
public void AuffaelligeFehlzeiten_MeldetHoheQuoteAbMindeststichprobe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var students = new FakeStudents([student]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
// 10 erfasste Termine, 3 davon unentschuldigt (30 % > 20 % Schwelle) — jetzt genug
// Stichprobe für eine Meldung.
for (var i = 0; i < 10; i++)
{
var session = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-i - 1) };
sessions.Save(session);
var status = i < 3 ? AttendanceStatus.Unexcused : AttendanceStatus.Present;
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = student.Id, Attendance = status });
}
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.AttendanceNotices);
Assert.Equal(student.FullName, item.StudentName);
Assert.Equal(30.0, item.AbsenceRatePercent);
}
[Fact]
public void NavigationCommands_RufenDelegatMitPassendemTabIndexAuf()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
var indices = new List<int>();
vm.OnNavigateToTab = idx => indices.Add(idx);
vm.NavigateToPlanningCommand.Execute(null);
vm.NavigateToExamsCommand.Execute(null);
vm.NavigateToParticipationCommand.Execute(null);
vm.NavigateToDocumentationCommand.Execute(null);
Assert.Equal([6, 4, 3, 8], indices);
}
[Fact]
public void FehlendeHausaufgaben_ListetNurSchuelerAusDerLetztenSitzung()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var studentMissing = new Student { FirstName = "Anna", LastName = "Beispiel" };
var studentDone = new Student { FirstName = "Ben", LastName = "Muster" };
var students = new FakeStudents([studentMissing, studentDone]);
var sessions = new FakeSessions([]);
var entries = new FakeEntries();
var olderSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-10) };
sessions.Save(olderSession);
entries.Add(new ParticipationEntry
{
SessionId = olderSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.MissingOpen, HomeworkMissing = true,
});
var lastSession = new ParticipationSession { GroupId = groupId, Date = today.AddDays(-1) };
sessions.Save(lastSession);
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentMissing.Id,
Homework = HomeworkStatus.MissingOverdue, HomeworkMissing = true,
});
entries.Add(new ParticipationEntry
{
SessionId = lastSession.Id, StudentId = studentDone.Id,
Homework = HomeworkStatus.Completed, HomeworkMissing = false,
});
var vm = NewVm(sessions: sessions, entries: entries, students: students);
vm.Initialize(groupId, "Testkurs");
var item = Assert.Single(vm.MissingHomeworkStudents);
Assert.Equal(studentMissing.FullName, item.StudentName);
Assert.Contains(lastSession.Date.ToString("dd.MM.yyyy"), vm.MissingHomeworkSessionLabel);
}
[Fact]
public void FehlendeHausaufgaben_OhneOffenePostenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasMissingHomework);
}
[Fact]
public void AnstehendeAufgaben_ZeigtNurOffeneAufgabenDieserGruppeSortiertNachFaelligkeit()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var groupId = Guid.NewGuid();
var otherGroupId = Guid.NewGuid();
var tasks = new FakeWorkTasks();
tasks.Add(new WorkTask { GroupId = groupId, Title = "Später fällig", DueDate = today.AddDays(10), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Bald fällig", DueDate = today.AddDays(1), Status = WorkTaskStatus.Open });
tasks.Add(new WorkTask { GroupId = groupId, Title = "Erledigt", DueDate = today, Status = WorkTaskStatus.Done });
tasks.Add(new WorkTask { GroupId = otherGroupId, Title = "Anderer Kurs", DueDate = today, Status = WorkTaskStatus.Open });
var vm = NewVm(tasks: tasks);
vm.Initialize(groupId, "Testkurs");
Assert.True(vm.HasGroupTasks);
Assert.Equal(["Bald fällig", "Später fällig"], vm.GroupTasks.Select(t => t.Title));
}
[Fact]
public void AnstehendeAufgaben_OhneAufgabenIstLeer()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
Assert.False(vm.HasGroupTasks);
}
[Fact]
public void NavigateToWorkload_RuftDelegatAuf()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
var called = false;
vm.OnNavigateToWorkload = () => called = true;
vm.NavigateToWorkloadCommand.Execute(null);
Assert.True(called);
}
[Fact]
public async Task AddGroupTask_SpeichertErgebnisMitGruppeUndAktualisiertListe()
{
var tasks = new FakeWorkTasks();
var (vm, _, _, _, _, _, groupId) = BuildScenario();
var vmWithTasks = NewVm(tasks: tasks);
vmWithTasks.Initialize(groupId, "Testkurs");
vmWithTasks.OnAddGroupTask = requestedGroupId =>
Task.FromResult<WorkTask?>(new WorkTask { Title = "Material einsammeln", GroupId = requestedGroupId });
await vmWithTasks.AddGroupTaskCommand.ExecuteAsync(null);
var saved = Assert.Single(tasks.GetByGroup(groupId));
Assert.Equal("Material einsammeln", saved.Title);
Assert.True(vmWithTasks.HasGroupTasks);
Assert.Equal("Material einsammeln", Assert.Single(vmWithTasks.GroupTasks).Title);
}
[Fact]
public async Task AddGroupTask_OhneDelegatMachtNichts()
{
var (vm, _, _, _, _, _, _) = BuildScenario();
await vm.AddGroupTaskCommand.ExecuteAsync(null);
Assert.False(vm.HasGroupTasks);
}
}
@@ -0,0 +1,397 @@
using LehrerApp.Core.AiPlanning;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class HazardAssessmentWizardViewModelTests
{
private static HazardAssessmentWizardViewModel Build(HazardAssessment? editing = null) => new(editing, "10c");
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-gbuwizard-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Neu_StartetAufSchritt0OhneKiOhneEditingKennzeichnung()
{
var vm = Build();
Assert.Equal(0, vm.StepIndex);
Assert.True(vm.IsBasicsStep);
Assert.True(vm.IsFirstStep);
Assert.Equal("Gefährdungsbeurteilung erstellen", vm.DialogTitle);
Assert.False(vm.CanUseAi);
}
[Fact]
public void Next_OhneTitel_BleibtAufSchritt0UndSetztFehler()
{
var vm = Build();
vm.NextCommand.Execute(null);
Assert.Equal(0, vm.StepIndex);
Assert.NotEqual("", vm.TitleError);
}
[Fact]
public void Next_MitTitel_GehtEinenSchrittWeiterUndAktualisiertStepFlags()
{
var vm = Build();
vm.Title = "Natrium in Wasser";
vm.NextCommand.Execute(null);
Assert.Equal(1, vm.StepIndex);
Assert.True(vm.IsSubstancesStep);
Assert.False(vm.IsFirstStep);
Assert.Equal("", vm.TitleError);
}
[Fact]
public void Back_AufSchritt0_BleibtAufSchritt0()
{
var vm = Build();
vm.BackCommand.Execute(null);
Assert.Equal(0, vm.StepIndex);
}
[Fact]
public void DurchlaufAllerSchritte_LandetAufZusammenfassung()
{
var vm = Build();
vm.Title = "Natrium in Wasser";
for (var i = 0; i < vm.StepCount - 1; i++) vm.NextCommand.Execute(null);
Assert.True(vm.IsSummaryStep);
Assert.True(vm.IsLastStep);
vm.NextCommand.Execute(null);
Assert.True(vm.IsSummaryStep, "Weiter auf dem letzten Schritt darf nicht darüber hinausgehen.");
}
[Fact]
public void AddSubstance_UndRemove_AktualisierenDieListeUndHasSubstances()
{
var vm = Build();
Assert.False(vm.HasSubstances);
vm.AddSubstanceCommand.Execute(null);
Assert.True(vm.HasSubstances);
var item = vm.Substances[0];
item.Name = "Natrium";
item.Amount = "ca. 0,5 g";
item.PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
item.RemoveCommand.Execute(null);
Assert.False(vm.HasSubstances);
Assert.Empty(vm.Substances);
}
[Fact]
public void AddHazard_TrimmtUndLeertDasEingabefeld()
{
var vm = Build();
vm.NewHazard = " Verätzungsgefahr ";
vm.AddHazardCommand.Execute(null);
Assert.Equal(["Verätzungsgefahr"], vm.Hazards);
Assert.Equal("", vm.NewHazard);
}
[Fact]
public void AddHazard_MitLeeremTextTutNichts()
{
var vm = Build();
vm.NewHazard = " ";
vm.AddHazardCommand.Execute(null);
Assert.Empty(vm.Hazards);
}
[Fact]
public void RemoveHazard_EntferntGenauDenGenanntenEintrag()
{
var vm = Build();
vm.NewHazard = "A"; vm.AddHazardCommand.Execute(null);
vm.NewHazard = "B"; vm.AddHazardCommand.Execute(null);
vm.RemoveHazardCommand.Execute("A");
Assert.Equal(["B"], vm.Hazards);
}
[Fact]
public void AddProtectiveMeasure_TrimmtUndLeertDasEingabefeld()
{
var vm = Build();
vm.NewProtectiveMeasure = " Schutzbrille ";
vm.AddProtectiveMeasureCommand.Execute(null);
Assert.Equal(["Schutzbrille"], vm.ProtectiveMeasures);
Assert.Equal("", vm.NewProtectiveMeasure);
}
[Fact]
public void Save_OhneTitel_SetztFehlerUndSpringtZurueckAufSchritt0()
{
var vm = Build();
vm.Title = "Etwas";
vm.NextCommand.Execute(null); // Schritt 1
vm.Title = " ";
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.Equal(0, vm.StepIndex);
Assert.NotEqual("", vm.TitleError);
}
[Fact]
public void Save_BautVollstaendigesErgebnisAusAllenSchritten()
{
var vm = Build();
vm.Title = "Natrium in Wasser";
vm.GroupLabel = "10c";
vm.DateText = "12.09.2026";
vm.KindName = "Lehrerversuch";
vm.Procedure = "Kurzbeschreibung";
vm.AddSubstanceCommand.Execute(null);
vm.Substances[0].Name = "Natrium";
vm.Substances[0].Amount = "ca. 0,5 g";
vm.Substances[0].HStatements = "H260";
vm.Substances[0].PictogramOptions.First(o => o.Value == GhsPictogram.Flammable).IsSelected = true;
vm.NewHazard = "Verpuffung"; vm.AddHazardCommand.Execute(null);
vm.NewProtectiveMeasure = "Schutzbrille"; vm.AddProtectiveMeasureCommand.Execute(null);
vm.FirstAid = "Wasser spülen";
vm.Disposal = "Abreagieren lassen";
vm.Notes = "Hinweis";
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
var result = vm.Result!;
Assert.Equal("Natrium in Wasser", result.Title);
Assert.Equal("10c", result.GroupLabel);
Assert.Equal(new DateOnly(2026, 9, 12), result.Date);
Assert.Equal(ExperimentKind.Lehrerversuch, result.Kind);
var substance = Assert.Single(result.Substances);
Assert.Equal("Natrium", substance.Name);
Assert.Equal([GhsPictogram.Flammable], substance.GhsPictograms);
Assert.Equal(["Verpuffung"], result.Hazards);
Assert.Equal(["Schutzbrille"], result.ProtectiveMeasures);
Assert.Equal("Wasser spülen", result.FirstAid);
Assert.Equal("Abreagieren lassen", result.Disposal);
Assert.Equal("Hinweis", result.Notes);
Assert.False(result.IsAiAssisted);
}
[Fact]
public void Konstruktor_MitEditingLaedtAlleFelderVor()
{
var editing = new HazardAssessment
{
Title = "Bestehende GBU",
GroupLabel = "9a",
Date = new DateOnly(2026, 1, 15),
Kind = ExperimentKind.Schuelerversuch,
Procedure = "Ablauf",
Substances = [new HazardSubstance { Name = "Salzsäure", GhsPictograms = [GhsPictogram.Corrosive] }],
Hazards = ["Verätzung"],
ProtectiveMeasures = ["Handschuhe"],
FirstAid = "Erste Hilfe Text",
Disposal = "Entsorgungstext",
Notes = "Notiz",
IsAiAssisted = true,
};
var vm = Build(editing);
Assert.Equal("Gefährdungsbeurteilung bearbeiten", vm.DialogTitle);
Assert.Equal("Bestehende GBU", vm.Title);
Assert.Equal("9a", vm.GroupLabel);
Assert.Equal("15.01.2026", vm.DateText);
Assert.Equal("Schülerversuch", vm.KindName);
Assert.Equal("Ablauf", vm.Procedure);
var substance = Assert.Single(vm.Substances);
Assert.Equal("Salzsäure", substance.Name);
Assert.True(substance.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
Assert.Equal(["Verätzung"], vm.Hazards);
Assert.Equal(["Handschuhe"], vm.ProtectiveMeasures);
Assert.Equal("Erste Hilfe Text", vm.FirstAid);
Assert.Equal("Entsorgungstext", vm.Disposal);
Assert.Equal("Notiz", vm.Notes);
Assert.True(vm.IsAiAssisted);
}
[Fact]
public void CanUseAi_OhneKiAbhaengigkeiten_IstFalseUndSubstanzenKoennenNichtRecherchieren()
{
var vm = Build();
vm.AddSubstanceCommand.Execute(null);
Assert.False(vm.CanUseAi);
Assert.False(vm.Substances[0].CanResearch);
}
[Fact]
public void CanUseAi_MitAngemeldeterKiUndUnitUndLesson_IstTrueUndSubstanzenKoennenRecherchieren()
{
var aiSettings = new AiSettingsService(BuildTempPath());
aiSettings.SetEnabled(true);
aiSettings.SetCredentialsAndToken("sebastian", "token-abc");
var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]),
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var unit = new Unit { Title = "Redoxreaktionen" };
var lesson = new Lesson { Topic = "Natrium in Wasser" };
var vm = new HazardAssessmentWizardViewModel(null, "10c", ai, aiSettings, unit, lesson);
vm.AddSubstanceCommand.Execute(null);
Assert.True(vm.CanUseAi);
Assert.True(vm.Substances[0].CanResearch);
}
}
public sealed class HazardSubstanceEditItemTests
{
[Fact]
public async Task Research_OhneOnResearch_TutNichtsUndBleibtOhneFehler()
{
var item = new HazardSubstanceEditItem { Name = "Natrium" };
await item.ResearchCommand.ExecuteAsync(null);
Assert.Equal("", item.ResearchError);
Assert.False(item.IsResearching);
}
[Fact]
public async Task Research_OhneNamen_SetztFehlerUndRuftDelegateNicht()
{
var called = false;
var item = new HazardSubstanceEditItem
{
Name = " ",
OnResearch = _ => { called = true; return Task.FromResult(new AiSubstanceResearchResponse()); },
};
await item.ResearchCommand.ExecuteAsync(null);
Assert.False(called);
Assert.Equal("Stoffname eingeben.", item.ResearchError);
}
[Fact]
public async Task Research_UebernimmtAlleFelderAusDerAntwort()
{
var item = new HazardSubstanceEditItem { Name = "Natrium" };
item.OnResearch = name =>
{
Assert.Equal("Natrium", name);
return Task.FromResult(new AiSubstanceResearchResponse
{
Name = "Natrium",
Cas = "7440-23-5",
SignalWord = "Gefahr",
HStatements = "H260",
PStatements = "P223, P231+P232",
ActivityRestriction = "Für Schüler ab der 5. Klasse freigegeben",
GhsPictograms = ["GHS02", "GHS05"],
});
};
await item.ResearchCommand.ExecuteAsync(null);
Assert.Equal("Natrium", item.Name);
Assert.Equal("7440-23-5", item.Cas);
Assert.Equal("Gefahr", item.SignalWord);
Assert.Equal("H260", item.HStatements);
Assert.Equal("P223, P231+P232", item.PStatements);
Assert.Equal("Für Schüler ab der 5. Klasse freigegeben", item.ActivityRestriction);
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
Assert.False(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
Assert.False(item.IsResearching);
Assert.Equal("", item.ResearchError);
}
[Fact]
public async Task Research_BeiFehler_SetztResearchErrorUndBehaeltAlteFelderUnveraendert()
{
var item = new HazardSubstanceEditItem { Name = "Unbekannt", HStatements = "H999" };
item.OnResearch = _ => throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
await item.ResearchCommand.ExecuteAsync(null);
Assert.Equal("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.", item.ResearchError);
Assert.Equal("H999", item.HStatements);
Assert.False(item.IsResearching);
}
}
public sealed class ExperimentKindDisplayTests
{
[Theory]
[InlineData(ExperimentKind.Lehrerversuch, "Lehrerversuch")]
[InlineData(ExperimentKind.Schuelerversuch, "Schülerversuch")]
[InlineData(ExperimentKind.Demonstrationsversuch, "Demonstrationsversuch")]
public void Label_UndFromLabel_SindZueinanderInvers(ExperimentKind kind, string label)
{
Assert.Equal(label, ExperimentKindDisplay.Label(kind));
Assert.Equal(kind, ExperimentKindDisplay.FromLabel(label));
}
[Fact]
public void FromLabel_UnbekanntFaelltAufLehrerversuchZurueck() =>
Assert.Equal(ExperimentKind.Lehrerversuch, ExperimentKindDisplay.FromLabel("Unbekannt"));
[Theory]
[InlineData("Schuelerversuch", ExperimentKind.Schuelerversuch)]
[InlineData("Demonstrationsversuch", ExperimentKind.Demonstrationsversuch)]
[InlineData("etwas anderes", ExperimentKind.Lehrerversuch)]
public void FromWireValue_LiestDenUnmarkiertenEnumNamenDerKiAntwort(string wire, ExperimentKind expected) =>
Assert.Equal(expected, ExperimentKindDisplay.FromWireValue(wire));
}
public sealed class GhsPictogramDisplayTests
{
[Theory]
[InlineData(GhsPictogram.Explosive, "GHS01")]
[InlineData(GhsPictogram.Corrosive, "GHS05")]
[InlineData(GhsPictogram.Environmental, "GHS09")]
public void Code_LiefertDenErwartetenGhsCode(GhsPictogram value, string code) =>
Assert.Equal(code, GhsPictogramDisplay.Code(value));
[Fact]
public void FromCode_IstFallUnabhaengigUndTrimmt() =>
Assert.Equal(GhsPictogram.Corrosive, GhsPictogramDisplay.FromCode(" ghs05 "));
[Fact]
public void FromCode_UnbekannterCodeLiefertNull() =>
Assert.Null(GhsPictogramDisplay.FromCode("GHS99"));
[Fact]
public void BuildOptions_MarkiertNurDieUebergebenenAlsAusgewaehlt()
{
var options = GhsPictogramDisplay.BuildOptions([GhsPictogram.Toxic, GhsPictogram.Flammable]);
Assert.Equal(9, options.Count);
Assert.True(options.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
Assert.True(options.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
Assert.False(options.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
}
}
@@ -1,3 +1,4 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
@@ -18,7 +19,7 @@ public sealed class LessonDialogViewModelTests
private static LessonDialogViewModel BuildVm(Guid unitId, Guid groupId, Lesson? editing = null,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null) =>
new(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(),
slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new FakeAttachmentStorage(),
unitId, groupId, "10c", "Chemie", [], [], editing);
/// Nutzer-Feedback: bei einer Klasse, die in mehreren Fächern unterrichtet wird (mehrere
@@ -92,7 +93,7 @@ public sealed class LessonDialogViewModelTests
{
var codes = new FakeShorthandCodes([new ShorthandCode { Code = "Tb" }, new ShorthandCode { Code = "SH" }]);
var vm = new LessonDialogViewModel(new FakeLessons(), codes, new FakeAlternativeLessonPaths([]),
new FakeTimetableSlots(), NewPeriodSchedule(),
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(),
Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], ["Plenum", "LDE", "Tb"], null); // "Tb" doppelt (Katalog + Historie), soll nur einmal erscheinen
Assert.Equal(["LDE", "Plenum", "SH", "Tb"], vm.ShorthandSuggestions);
@@ -155,7 +156,7 @@ public sealed class LessonDialogViewModelTests
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
new FakeTimetableSlots(), NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null)
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null)
{
Topic = "Brechung", DateText = "01.09.2025", StartTimeText = "11:45",
};
@@ -179,6 +180,58 @@ public sealed class LessonDialogViewModelTests
Assert.Single(lessons.GetByUnit(unitId));
}
[Fact]
public void AddAttachment_LaedtHochUndSaveUebernimmtIhnInDasErgebnis()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
vm.Topic = "Thema"; vm.DateText = "01.09.2025";
vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
var attachment = Assert.Single(vm.Result!.Attachments);
Assert.Equal("gbu-natrium.pdf", attachment.FileName);
Assert.Equal(3, attachment.SizeBytes);
}
[Fact]
public void AddAttachment_ZuGrosseDateiWirdAbgelehnt()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
vm.AddAttachment("riesig.pdf", new MemoryStream(new byte[IAttachmentStorage.MaxSizeBytes + 1]));
Assert.Empty(vm.Attachments);
Assert.NotEqual("", vm.AttachmentError);
}
[Fact]
public void RemoveAttachmentCommand_EntferntDenAnhangAusDerListe()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
vm.AddAttachment("gbu-natrium.pdf", new MemoryStream([1, 2, 3]));
var item = vm.Attachments[0];
vm.RemoveAttachmentCommand.Execute(item);
Assert.Empty(vm.Attachments);
}
[Fact]
public void BearbeiteteStunde_LaedtVorhandeneAnhaengeVor()
{
var editing = new Lesson
{
Attachments = [new DocumentAttachment { StorageId = "abc", FileName = "gbu.pdf", SizeBytes = 42 }],
};
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing);
var attachment = Assert.Single(vm.Attachments);
Assert.Equal("gbu.pdf", attachment.FileName);
}
[Fact]
public void HasHomeworkText_OhneHausaufgabe_IstFalse()
{
@@ -305,7 +358,7 @@ public sealed class LessonDialogViewModelTests
};
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), alternativePaths,
new FakeTimetableSlots(), NewPeriodSchedule(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
new FakeTimetableSlots(), NewPeriodSchedule(), new FakeAttachmentStorage(), Guid.NewGuid(), Guid.NewGuid(), "10c", "Chemie", [], [], editing);
Assert.True(vm.Phases[0].HasAlternativePath);
Assert.Equal("Kurzversion", vm.Phases[0].AlternativePathName);
@@ -466,10 +519,11 @@ public sealed class LessonDialogViewModelTests
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
// Nächster Dienstag nach dem 06.01.2099 (einem Dienstag) ist der 13.01.2099.
Assert.Equal("13.01.2099", vm.DateText);
Assert.Equal(3, vm.LessonNumber);
}
[Fact]
@@ -484,10 +538,47 @@ public sealed class LessonDialogViewModelTests
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, NewPeriodSchedule(), unitId, groupId, "10c", "Chemie", [], [], null);
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
var suggested = DateOnly.ParseExact(vm.DateText, "dd.MM.yyyy");
Assert.True(suggested >= DateOnly.FromDateTime(DateTime.Today));
Assert.Equal(weekday, suggested.DayOfWeek);
Assert.Equal(3, vm.LessonNumber);
}
[Fact]
public void NeueStunde_Doppelstunde_SchlaegtDieErstePeriodeAlsStundennummerVor()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var futureLessonDate = new DateOnly(2099, 1, 6); // Dienstag
var lessons = new FakeLessons();
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = futureLessonDate, LessonNumber = 3 });
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 4 });
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = DayOfWeek.Tuesday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, NewPeriodSchedule(), new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
Assert.Equal(3, vm.LessonNumber); // frühere Periode, nicht 4
}
[Fact]
public void NeueStunde_MitStundenvorschlag_UebernimmtBeginnAusStundenraster()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var weekday = DateTime.Today.DayOfWeek;
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 2 });
var periodSchedule = NewPeriodSchedule();
periodSchedule.SetPeriods([new PeriodTimeEntry { PeriodNumber = 2, Start = new TimeOnly(8, 50), End = new TimeOnly(9, 35) }]);
var vm = new LessonDialogViewModel(new FakeLessons(), new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
slots, periodSchedule, new FakeAttachmentStorage(), unitId, groupId, "10c", "Chemie", [], [], null);
Assert.Equal(2, vm.LessonNumber);
Assert.Equal("08:50", vm.StartTimeText);
}
}
@@ -0,0 +1,90 @@
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 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);
}
}
@@ -97,4 +97,34 @@ public sealed class LessonViewerViewModelTests
Assert.Equal("ab 12:00", vm.PhaseGroups[0].Phases[0].TimeDisplay);
Assert.Equal("ab 12:00", vm.PhaseGroups[1].Phases[0].TimeDisplay);
}
// ── Verzweigung aus dem Viewer (4.5.3) ────────────────────────────────────────
[Fact]
public void NavigateToParticipation_RuftDelegatMitTabIndexDreiAuf()
{
var groupId = Guid.NewGuid();
var lesson = new Lesson { GroupId = groupId, Topic = "Brechung", Date = new DateOnly(2025, 9, 1) };
var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([]));
int? navigatedTab = null;
vm.OnNavigateToTab = idx => navigatedTab = idx;
vm.NavigateToParticipationCommand.Execute(null);
Assert.Equal(groupId, vm.GroupId);
Assert.Equal(3, navigatedTab);
}
[Fact]
public void NavigateToGrades_RuftDelegatMitTabIndexFuenfAuf()
{
var lesson = new Lesson { GroupId = Guid.NewGuid(), Topic = "Brechung", Date = new DateOnly(2025, 9, 1) };
var vm = new LessonViewerViewModel(lesson, new FakeAlternativeLessonPaths([]));
int? navigatedTab = null;
vm.OnNavigateToTab = idx => navigatedTab = idx;
vm.NavigateToGradesCommand.Execute(null);
Assert.Equal(5, navigatedTab);
}
}
@@ -59,4 +59,49 @@ public sealed class ParticipationSessionEditingTests
Assert.Equal("Nachbesprechung Elektrizität", sessions.GetById(session.Id)?.Comment);
Assert.Contains("Nachbesprechung Elektrizität", vm.SelectedSessionDisplay);
}
[Fact]
public async Task LoeschenBefehl_FragtBestaetigungAbUndEntferntSessionMitsamtBewertungen()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
var sessions = new FakeSessions([session]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = session.Id, StudentId = Guid.NewGuid() });
var vm = new ParticipationTabViewModel(
sessions, entries, new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
int? passedEntryCount = null;
vm.OnConfirmDeleteSession = (_, count) =>
{
passedEntryCount = count;
return Task.FromResult(true);
};
await vm.DeleteSessionCommand.ExecuteAsync(null);
Assert.Equal(1, passedEntryCount);
Assert.Null(sessions.GetById(session.Id));
}
[Fact]
public async Task LoeschenBefehl_BrichtBeiAbgelehnterBestaetigungAb()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession { GroupId = group.Id, Date = new DateOnly(2026, 8, 19) };
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.Initialize(group.Id, group.SchoolYear);
vm.SelectedSession = vm.Sessions.Single(s => s.Id == session.Id);
vm.OnConfirmDeleteSession = (_, _) => Task.FromResult(false);
await vm.DeleteSessionCommand.ExecuteAsync(null);
Assert.NotNull(sessions.GetById(session.Id));
}
}
@@ -0,0 +1,186 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class PdfExportServiceTests
{
private static readonly PdfExportService Service = new();
private static void AssertIsPdf(byte[] bytes)
{
Assert.True(bytes.Length > 500, "PDF ist verdächtig klein.");
Assert.Equal("%PDF"u8.ToArray(), bytes[..4]);
}
[Fact]
public void BuildSeatingPlanPdf_VollstaendigerPlan_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Standard", "Raum B204 · 2 × 3 Plätze",
Columns: 3, IsBoardAtBottom: false, ColumnGapWidths: [0, 90],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, false),
new SeatPrintCell(0, 2, "Muster, Ben", false),
new SeatPrintCell(1, 0, null, false),
new SeatPrintCell(1, 1, "Probe, Cem", false),
new SeatPrintCell(1, 2, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
/// Ausgeblendete Plätze (kein Tisch im Raum, siehe SeatingPlan.HiddenSeats) müssen als leere
/// Rasterposition mitgedruckt werden, ohne die Erzeugung zu stören.
[Fact]
public void BuildSeatingPlanPdf_MitAusgeblendetenPlaetzenUndTafelUnten_LiefertGueltigesPdf()
{
var data = new SeatingPlanPrintData("Physikraum", "",
Columns: 2, IsBoardAtBottom: true, ColumnGapWidths: [],
Seats:
[
new SeatPrintCell(0, 0, "Beispiel, Anna", false),
new SeatPrintCell(0, 1, null, true),
new SeatPrintCell(1, 0, null, true),
new SeatPrintCell(1, 1, null, false),
]);
AssertIsPdf(Service.BuildSeatingPlanPdf(data));
}
[Fact]
public void BuildGradeListPdf_MatrixMitMehrerenSpalten_LiefertGueltigesPdf()
{
var data = new GradeListPrintData("8a Chemie", "2026/27", "1. Halbjahr",
ColumnHeaders: ["12.09. Klausur 1", "Mündlich 20.09.", "Hausaufgaben 01.10."],
Rows:
[
new GradeListPrintRow("Beispiel, Anna", ["2", "1", ""], "1.67"),
new GradeListPrintRow("Muster, Ben", ["abwesend", "3", "2"], "2.50"),
new GradeListPrintRow("Probe, Cem", ["", "", ""], ""),
]);
AssertIsPdf(Service.BuildGradeListPdf(data));
}
/// Ein Kurs kann übers Jahr viele Notenspalten ansammeln - die Tabelle muss auch dann noch
/// erzeugbar sein (QuestPDF staucht die Spalten, statt zu werfen).
[Fact]
public void BuildGradeListPdf_VieleSpalten_LiefertGueltigesPdf()
{
var headers = Enumerable.Range(1, 20).Select(i => $"Note {i}").ToList();
var rows = Enumerable.Range(1, 30).Select(i => new GradeListPrintRow(
$"Schüler {i}", [.. Enumerable.Repeat("2", 20)], "2.00")).ToList();
AssertIsPdf(Service.BuildGradeListPdf(new GradeListPrintData("LK Chemie", "2026/27",
"Gesamtes Schuljahr", headers, rows)));
}
[Fact]
public void BuildExamStatisticsPdf_MitVerteilungUndAufgabenanalyse_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Klausur 1 - Säuren und Basen", "12.09.2026", "E",
Stats:
[
new LabelValue("Bewertet", "24"),
new LabelValue("Abwesend", "2"),
new LabelValue("Durchschnitt", "2.71"),
new LabelValue("Median", "3"),
new LabelValue("Anteil nicht ausreichend (Note 5/6)", "8.3 %"),
],
Distribution:
[
new GradeDistributionPrintRow("1", 3, 12.5),
new GradeDistributionPrintRow("2", 8, 33.3),
new GradeDistributionPrintRow("3", 7, 29.2),
new GradeDistributionPrintRow("4", 4, 16.7),
new GradeDistributionPrintRow("5", 2, 8.3),
new GradeDistributionPrintRow("6", 0, 0),
],
TaskAnalysis:
[
new TaskAnalysisPrintRow("1. Titration", 78.4, false),
new TaskAnalysisPrintRow("2. pH-Berechnung", 42.1, true),
]);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
/// Auch ohne Aufgabenstruktur (Klausur ohne Teilaufgaben) und ohne Niveau muss der
/// Notenspiegel druckbar sein - der Abschnitt Aufgabenanalyse entfällt dann einfach.
[Fact]
public void BuildExamStatisticsPdf_OhneAufgabenUndNiveau_LiefertGueltigesPdf()
{
var data = new ExamStatisticsPrintData("Test", "01.10.2026", "",
[new LabelValue("Bewertet", "0")], [], []);
AssertIsPdf(Service.BuildExamStatisticsPdf(data));
}
[Fact]
public void BuildCompetencyReportPdf_LiefertGueltigesPdf()
{
var data = new CompetencyReportPrintData("8a Chemie", "Beispiel, Anna",
"3 von 5 behandelt · 2 geprüft · 2 mit Ergebnisdaten", "Wiederholungsbedarf unter 60 %",
[
new CompetencyReportPrintRow("Stoff-Teilchen (ST)", "ST1",
"Aggregatzustände auf Teilchenebene erklären", "2× behandelt", "1× geprüft",
"72.5 % · 24 Schüler · 3 Aufgabenwerte", "85 % · 3 Aufgabenwerte", "Stand solide"),
new CompetencyReportPrintRow("Chemische Reaktion (CR)", "CR2",
"Reaktionsgleichungen aufstellen", "1× behandelt · 1× geplant", "1× geprüft",
"48.3 % · 24 Schüler · 2 Aufgabenwerte", "40 % · 2 Aufgabenwerte", "Wiederholungsbedarf"),
new CompetencyReportPrintRow("Energie (EN)", "", "Energiediagramme deuten",
"", "1× geplant", "", "", "Noch keine Bewertung"),
]);
AssertIsPdf(Service.BuildCompetencyReportPdf(data));
}
[Fact]
public void BuildStudentDocumentationPdf_MitVertraulichenUndEntwurfsEintraegen_LiefertGueltigesPdf()
{
var data = new StudentDocumentationPrintData("Beispiel, Anna",
[
new DocumentationPrintEntry("12.09.2026", "Beobachtung", "Unterricht gestört",
"Wiederholt dazwischengerufen, nach Ermahnung gebessert.",
["Kritisch"], IsConfidential: false, IsDraft: false),
new DocumentationPrintEntry("20.09.2026", "Elterngespräch", "Telefonat Mutter",
"Absprache: wöchentliche Rückmeldung im Hausaufgabenheft.",
[], IsConfidential: true, IsDraft: false),
new DocumentationPrintEntry("01.10.2026", "Beobachtung", "Schnellnotiz",
"", ["Nacharbeiten"], IsConfidential: false, IsDraft: true),
]);
AssertIsPdf(Service.BuildStudentDocumentationPdf(data));
}
[Fact]
public void BuildHazardAssessmentPdf_MitStoffenUndKiHinweis_LiefertGueltigesPdf()
{
var data = new HazardAssessmentPrintData(
"Natrium in Wasser", "10c", "12.09.2026", "Lehrerversuch",
"Ein kleines Stück Natrium wird in eine Schale mit Wasser gegeben.",
[
new HazardSubstancePrintRow("Natrium", "7440-23-5", "ca. 0,5 g", "GHS02, GHS05", "Gefahr",
"H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378",
"Abgabe an unter 18-Jährige verboten"),
],
["Wasserstoffbildung, Verpuffungsgefahr", "Verätzungsgefahr durch Natronlauge"],
["Schutzbrille", "Sicherheitsabstand einhalten", "Abzug/Freiluftversuch"],
"Betroffene Stellen mit viel Wasser spülen, Arzt hinzuziehen.",
"Reste unter Aufsicht vollständig abreagieren lassen.",
"", IsAiAssisted: true);
AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
}
[Fact]
public void BuildHazardAssessmentPdf_OhneStoffeUndOhneKiHinweis_LiefertGueltigesPdf()
{
var data = new HazardAssessmentPrintData(
"Papierchromatographie", "8b", "", "Schülerversuch", "", [], [], [], "", "", "", IsAiAssisted: false);
AssertIsPdf(Service.BuildHazardAssessmentPdf(data));
}
}
@@ -20,7 +20,7 @@ public class PlanningTabViewModelTests
var lessons = new FakeLessons();
var vm = new PlanningTabViewModel(units, lessons, groups, subjects, competencyDomains,
TestSupport.BuildAiSettingsService());
TestSupport.BuildAiSettingsService(), new FakeSessions([]));
vm.Initialize(groupId);
return (vm, units, lessons, groupId);
@@ -227,4 +227,71 @@ public class PlanningTabViewModelTests
Assert.Equal(["L->AB01", "Plenum"], vm.KnownShorthands);
}
[Fact]
public void CreateParticipationSession_LegtSitzungMitDatumUndThemaDerStundeAn()
{
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 lesson = new Lesson
{
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
};
lessons.Add(lesson);
vm.RefreshPlanning(unit.Id, lesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.CreateParticipationSessionCommand.Execute(null);
var created = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, created.LessonId);
Assert.Equal(lesson.Date, created.Date);
Assert.Equal("Brechung", created.Comment);
Assert.Equal("Sitzung aus der Stunde erstellt.", notified);
}
[Fact]
public void CreateParticipationSession_ZweiterAufrufLegtKeineDoppelteSitzungAn()
{
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 lesson = new Lesson
{
UnitId = unit.Id, GroupId = groupId, Date = new DateOnly(2025, 9, 1), Topic = "Brechung",
};
lessons.Add(lesson);
vm.RefreshPlanning(unit.Id, lesson.Id);
vm.SelectedLesson = vm.Lessons.Single(l => l.Id == lesson.Id);
vm.CreateParticipationSessionCommand.Execute(null);
string? notified = null;
vm.OnNotify = m => notified = m;
vm.CreateParticipationSessionCommand.Execute(null);
Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal("Für diese Stunde 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()
{
@@ -0,0 +1,139 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SchoolDayWeatherSummaryTests
{
private static readonly DateOnly Day = new(2026, 8, 24);
[Fact]
public void From_GewitterHatVorrangVorSturmUndRegen()
{
var snapshot = Snapshot(
Hour(8, code: 61, rain: 2, gust: 70),
Hour(10, code: 95, rain: 1, gust: 80));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
Assert.Equal("⛈️", summary.Symbol);
}
[Fact]
public void From_BoeAbBeaufortAcht_WirdAlsSturmAngezeigt()
{
var summary = SchoolDayWeatherSummary.From(Snapshot(Hour(10, gust: 62)), Day);
Assert.Equal(SchoolDayWeatherKind.Storm, summary!.Kind);
Assert.Equal("💨", summary.Symbol);
}
[Fact]
public void From_NaechtlicheBoeBeeinflusstSchultagNicht()
{
var snapshot = Snapshot(Hour(10, cloud: 10), Hour(23, gust: 90));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(20, summary!.MaxWindGustKmh);
Assert.Equal(SchoolDayWeatherKind.Sunny, summary!.Kind);
}
[Fact]
public void From_VerwendetFuerKennzahlenNurAchtBisSechzehnUhr()
{
var snapshot = Snapshot(
Hour(7, temperature: 5, rain: 3, probability: 95, sunshineMinutes: 0),
Hour(8, temperature: 12, probability: 20, sunshineMinutes: 30),
Hour(16, temperature: 17, probability: 65, sunshineMinutes: 45),
Hour(17, temperature: 25, rain: 4, probability: 99, sunshineMinutes: 60));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(12, summary!.MinTemperatureC);
Assert.Equal(17, summary.MaxTemperatureC);
Assert.Equal(0, summary.PrecipitationMm);
Assert.Equal(65, summary.MaxHourlyPrecipitationProbabilityPercent);
Assert.Equal(1.3, summary.SunshineDurationHours);
Assert.Contains("0816 Uhr", summary.Tooltip);
Assert.Contains("Regenwahrscheinlichkeit: bis 65 % (stündlich)", summary.Tooltip);
Assert.Contains($"Sonnenschein: {summary.SunshineDurationHours:0.#} h", summary.Tooltip);
}
[Fact]
public void From_AmtlicheGewitterwarnung_PraegtSymbolUndTooltip()
{
var snapshot = Snapshot(Hour(10, cloud: 20));
snapshot.Warnings.Add(new WeatherWarning
{
Identifier = "w1", Event = "GEWITTER", Headline = "Amtliche Warnung vor Gewitter",
Instruction = "Aufenthalt im Freien vermeiden.", Severity = "Severe",
Onset = Day.ToDateTime(new TimeOnly(12, 0)), Expires = Day.ToDateTime(new TimeOnly(16, 0)),
});
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Thunderstorm, summary!.Kind);
Assert.Contains("Amtliche Warnung", summary.Tooltip);
Assert.Contains("Aufenthalt im Freien", summary.Tooltip);
}
[Fact]
public void From_RegenSummiertStundenUndZeigtTemperaturspanne()
{
var snapshot = Snapshot(
Hour(8, temperature: 12, rain: 0.1),
Hour(12, temperature: 17, rain: 0.4));
var summary = SchoolDayWeatherSummary.From(snapshot, Day);
Assert.Equal(SchoolDayWeatherKind.Rain, summary!.Kind);
Assert.Equal(0.5, summary.PrecipitationMm);
Assert.Contains("1217 °C", summary.Tooltip);
}
[Fact]
public void WarningTouchesDate_MehrtaegigeVorwarnung_TrifftAlleUeberlapptenTage()
{
var warning = new WeatherWarning
{
Onset = Day.ToDateTime(new TimeOnly(18, 0)),
Expires = Day.AddDays(2).ToDateTime(new TimeOnly(8, 0)),
};
Assert.True(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(1)));
Assert.False(SchoolDayWeatherSummary.WarningTouchesDate(warning, Day.AddDays(3)));
}
[Fact]
public void WeekCellItem_WetterOderWarnung_AktiviertGemeinsameHoverFlaeche()
{
var cell = WeekCellItem.WeekdayHeader(DayOfWeek.Monday, Day, false);
Assert.False(cell.HasWeatherInformation);
cell.SetWeather(SchoolDayWeatherSummary.From(Snapshot(Hour(10)), Day), []);
Assert.True(cell.HasWeatherInformation);
cell.SetWeather(null, [new WeatherWarning { Headline = "Amtliche Warnung" }]);
Assert.True(cell.HasWeatherInformation);
Assert.Contains("Amtliche Warnung", cell.WeatherTooltip);
}
private static WeatherSnapshot Snapshot(params WeatherForecastHour[] hours) => new()
{
RetrievedAt = DateTime.UtcNow,
Forecast = hours.ToList(),
};
private static WeatherForecastHour Hour(int hour, int? code = 0, double? temperature = 20,
double? rain = 0, double? gust = 20, double? cloud = 20,
double? probability = null, double? sunshineMinutes = null) => new()
{
ValidAt = Day.ToDateTime(new TimeOnly(hour, 0)), WeatherCode = code,
TemperatureC = temperature, PrecipitationMm = rain, WindGustKmh = gust,
CloudCoverPercent = cloud, PrecipitationProbabilityPercent = probability,
SunshineDurationMinutes = sunshineMinutes,
};
}
@@ -115,6 +115,218 @@ public sealed class SeatingPlanViewModelTests
Assert.Equal(2, plans.GetById(plan.Id)!.Assignments.Count);
}
// ── Ausgeblendete Plätze (fehlender Tisch in einer Reihe) ─────────────────────────────
[Fact]
public void LoadPlan_LaedtAusgeblendetePlaetzeAusDemPlan()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
};
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
Assert.False(vm.Seats[0].IsHidden);
Assert.True(vm.Seats[1].IsHidden);
}
[Fact]
public void ToggleSeatHidden_LeererPlatz_BlendetAusUndSpeichertUndKannWiederEingeblendetWerden()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2 };
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.ToggleSeatHidden(vm.Seats[1]);
Assert.True(vm.Seats[1].IsHidden);
var hidden = Assert.Single(plans.GetById(plan.Id)!.HiddenSeats);
Assert.Equal((0, 1), (hidden.Row, hidden.Column));
vm.ToggleSeatHidden(vm.Seats[1]);
Assert.False(vm.Seats[1].IsHidden);
Assert.Empty(plans.GetById(plan.Id)!.HiddenSeats);
}
[Fact]
public void ToggleSeatHidden_BelegterPlatz_TutNichts()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = student.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.ToggleSeatHidden(vm.Seats[0]);
Assert.False(vm.Seats[0].IsHidden);
}
/// Ein ausgeblendeter Platz darf nie ein Ziel für Drag&Drop/Zuordnung sein - sonst hätte man
/// gleichzeitig "kein Tisch hier" und einen dort sitzenden Schüler.
[Fact]
public void AssignStudent_AufAusgeblendetenPlatz_WirdIgnoriert()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = student.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 0 }],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.AssignStudent(vm.StudentOptions.Single(o => o.StudentId == student.Id), vm.Seats[0]);
Assert.False(vm.Seats[0].IsOccupied);
Assert.Empty(plans.GetById(plan.Id)!.Assignments);
}
[Fact]
public void MoveSeat_AufAusgeblendetenPlatz_WirdIgnoriert()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = student.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, new FakeStudents([student]), memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId);
Assert.False(vm.Seats[1].IsOccupied);
}
// ── Unterrichtsmodus (14.x): Sitzung mit einer konkreten Stunde verknüpfen ────────────────
[Fact]
public void SelectOrCreateSessionForLesson_BereitsVerknuepfteSitzungVorhanden_WaehltSieAus()
{
var groupId = Guid.NewGuid();
var lesson = new Lesson { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var existingSession = new ParticipationSession
{ GroupId = groupId, Date = lesson.Date, LessonId = lesson.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(lesson);
Assert.Equal(existingSession.Id, vm.SelectedSession?.Id);
Assert.Single(sessions.GetByGroup(groupId));
}
[Fact]
public void SelectOrCreateSessionForLesson_KeineSitzungVorhanden_LegtVerknuepfteAnUndWaehltSieAus()
{
var groupId = Guid.NewGuid();
var lesson = new Lesson
{ GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var sessions = new FakeSessions([]);
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(lesson);
var created = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(lesson.Id, created.LessonId);
Assert.Equal(lesson.Topic, created.Comment);
Assert.Equal(vm.SelectedSession?.Id, created.Id);
}
[Fact]
public void SelectOrCreateSessionForLesson_ArchivierteGruppe_LegtKeineSitzungAn()
{
var groupId = Guid.NewGuid();
var lesson = new Lesson
{ GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var sessions = new FakeSessions([]);
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: true);
vm.SelectOrCreateSessionForLesson(lesson);
Assert.Empty(sessions.GetByGroup(groupId));
Assert.Null(vm.SelectedSession);
}
[Fact]
public void ShowSeatUndCanToggleHidden_HaengenVonBearbeitungsmodusUndBelegungAb()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = student.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
HiddenSeats = [new HiddenSeat { Row = 0, Column = 1 }],
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
memberships, new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
// Ansichtsmodus: der ausgeblendete Platz wird nicht gerendert, belegte Plätze können
// ohnehin nicht ausgeblendet werden.
Assert.False(vm.Seats[1].ShowSeat);
Assert.False(vm.Seats[0].CanToggleHidden);
Assert.False(vm.Seats[1].CanToggleHidden);
vm.IsEditMode = true;
// Bearbeitungsmodus: der ausgeblendete Platz bleibt sichtbar (abgeblendet), um ihn wieder
// einblenden zu können - der belegte Platz kann weiterhin nicht ausgeblendet werden.
Assert.True(vm.Seats[1].ShowSeat);
Assert.False(vm.Seats[0].CanToggleHidden);
Assert.True(vm.Seats[1].CanToggleHidden);
}
[Fact]
public void Ansichtsmodus_VerhindertVerschiebenUndBlendetBearbeitungsbefehleAus()
{
@@ -146,6 +358,66 @@ public sealed class SeatingPlanViewModelTests
Assert.All(vm.Seats, seat => Assert.True(seat.CanEdit));
}
[Fact]
public void Ansichtsmodus_ZeigtAnwesenheitUndHausaufgabeUndBlendetAbwesendeAb()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var session = new ParticipationSession { GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today) };
var entries = new FakeEntries();
entries.Add(new ParticipationEntry
{
SessionId = session.Id, StudentId = student.Id,
Attendance = AttendanceStatus.Excused, Homework = HomeworkStatus.MissingOpen,
});
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
new FakeSessions([session]), entries, new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
var seat = Assert.Single(vm.Seats);
Assert.True(seat.HasAttendanceBadge);
Assert.True(seat.HasHomeworkBadge);
Assert.Equal(0.42, seat.LessonOpacity);
}
[Fact]
public void SituationsTags_WerdenProSchuelerUndSitzungInEinemEntwurfZusammengefuehrt()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var session = new ParticipationSession
{
GroupId = groupId, Date = DateOnly.FromDateTime(DateTime.Today), LessonId = Guid.NewGuid(),
};
var docs = new FakeDocumentation();
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1,
Assignments = [new SeatAssignment { StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
new FakeSessions([session]), new FakeEntries(), new FakeAspects(), docs);
vm.Initialize(groupId, isReadOnly: false);
var seat = Assert.Single(vm.Seats);
seat.SituationTags.Single(t => t.Text == "Mitarbeit verweigert").ToggleCommand.Execute(null);
seat.SituationTags.Single(t => t.Text == "Material vergessen").ToggleCommand.Execute(null);
var draft = Assert.Single(docs.GetByStudent(student.Id));
Assert.True(draft.IsDraft);
Assert.Equal(session.Id, draft.ParticipationSessionId);
Assert.Equal(session.LessonId, draft.LessonId);
Assert.Equal(["Mitarbeit verweigert", "Material vergessen"], draft.Tags);
}
[Fact]
public void Tischabstaende_WerdenBeimBearbeitenProSpaltengrenzeGespeichert()
{
@@ -200,6 +472,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);
@@ -209,6 +482,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]
@@ -224,4 +547,213 @@ public sealed class SeatingPlanViewModelTests
Assert.False(vm.CanEdit);
Assert.Empty(sessions.GetByGroup(groupId));
}
[Fact]
public void Initialize_LegtOhneVorhandeneSitzungKeineGeistersitzungAn()
{
// Regressionstest: Initialize() läuft schon beim bloßen Navigieren zur Kursübersicht
// (GroupDetailViewModel.LoadGroup ruft SeatingPlanTab.Initialize für jede Gruppe auf),
// unabhängig davon, ob der Sitzplan-Tab überhaupt angesehen wird. Vorher legte
// LoadTodaySessions() dabei sofort eine "Sitzplan"-Sitzung für heute an.
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var sessions = new FakeSessions([]);
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
Assert.Empty(sessions.GetByGroup(groupId));
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()
{
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 vm = new SeatingPlanTabViewModel(new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
sessions, new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.OnAssessStudent = _ => Task.CompletedTask;
Assert.Empty(sessions.GetByGroup(groupId));
await vm.AssessStudent(vm.Seats.Single());
var session = Assert.Single(sessions.GetByGroup(groupId));
Assert.Equal(DateOnly.FromDateTime(DateTime.Today), session.Date);
Assert.Equal("Sitzplan", session.Comment);
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()
{
var groupId = Guid.NewGuid();
var anna = new Student { FirstName = "Anna", LastName = "A" };
var ben = new Student { FirstName = "Ben", LastName = "B" };
var cara = new Student { FirstName = "Cara", LastName = "C" };
var students = new FakeStudents([anna, ben, cara]);
var memberships = new FakeMemberships([
new GroupMembership { GroupId = groupId, StudentId = anna.Id },
new GroupMembership { GroupId = groupId, StudentId = ben.Id },
new GroupMembership { GroupId = groupId, StudentId = cara.Id },
]);
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Klausur", Room = "B204", Rows = 1, Columns = 3,
Assignments =
[
new SeatAssignment { Row = 0, Column = 0, StudentId = anna.Id },
new SeatAssignment { Row = 0, Column = 1, StudentId = ben.Id },
new SeatAssignment { Row = 0, Column = 2, StudentId = cara.Id },
],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.ShuffleSeatsCommand.Execute(null);
var all = plans.GetByGroup(groupId);
Assert.Equal(2, all.Count);
var shuffled = Assert.Single(all, p => p.Id != plan.Id);
Assert.Equal("B204", shuffled.Room);
Assert.Equal(1, shuffled.Rows);
Assert.Equal(3, shuffled.Columns);
// Dieselben drei Schüler auf denselben drei Koordinaten, nur die Zuordnung untereinander
// wurde neu gewürfelt.
Assert.Equal(new[] { anna.Id, ben.Id, cara.Id }.OrderBy(id => id),
shuffled.Assignments.Select(a => a.StudentId).OrderBy(id => id));
Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) },
shuffled.Assignments.Select(a => (a.Row, a.Column)).OrderBy(t => t.Item2));
// Ursprungsplan bleibt unverändert erhalten.
Assert.Equal(3, plans.GetById(plan.Id)!.Assignments.Count);
Assert.Equal(vm.SelectedPlan?.Id, shuffled.Id);
}
[Fact]
public void ShuffleSeatsCommand_OhneBearbeitungsmodus_NichtAusfuehrbar()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 };
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
Assert.False(vm.ShuffleSeatsCommand.CanExecute(null));
vm.IsEditMode = true;
Assert.True(vm.ShuffleSeatsCommand.CanExecute(null));
}
}
@@ -1,11 +1,13 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
using System.Linq;
using Xunit;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Tests;
@@ -14,7 +16,9 @@ public sealed class SettingsViewModelTests
private static SettingsViewModel BuildViewModel(FakeSchoolHolidays? holidays = null,
FakeSupervisionDuties? supervisionDuties = null,
FakeSubjects? subjects = null, FakeCompetencyDomains? competencyDomains = null,
EventQueue? eventQueue = null)
EventQueue? eventQueue = null, SyncKeyStatus? syncKeyStatus = null,
SyncKeyRecoveryService? syncKeyRecovery = null, AppearanceSettingsService? appearance = null,
TrashViewModel? trashTab = null)
{
// Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad erst bei SetState,
// das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben.
@@ -25,14 +29,155 @@ 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), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(),
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger());
eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(),
syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(),
syncKeyRecovery ?? TestSupport.BuildSyncKeyRecoveryService(),
appearance ?? TestSupport.BuildAppearanceSettingsService(),
trashTab ?? TestSupport.BuildTrashViewModel());
}
// ── Darstellung (12.4) ────────────────────────────────────────────────────────────────────
[Fact]
public void SelectedTheme_SpiegeltDenGespeichertenStandBeimStartWider()
{
var appearance = TestSupport.BuildAppearanceSettingsService();
appearance.Save(AppTheme.Dark);
var vm = BuildViewModel(appearance: appearance);
Assert.Equal(AppTheme.Dark, vm.SelectedTheme);
Assert.Equal("Dunkel", vm.SelectedThemeName);
}
[Fact]
public void SelectedThemeName_Aendern_PersistiertUndBenachrichtigtDenCodeBehind()
{
var appearance = TestSupport.BuildAppearanceSettingsService();
var vm = BuildViewModel(appearance: appearance);
AppTheme? notified = null;
vm.OnThemeChanged = t => notified = t;
vm.SelectedThemeName = "Dunkel";
Assert.Equal(AppTheme.Dark, notified);
Assert.Equal(AppTheme.Dark, appearance.Load());
}
// ── Sync-Schlüssel: Warnung + Wiederherstellungscode (10.3.2) ────────────────────────────
//
// Der "glückliche" Redeem-Pfad (gültiger Code + Datei + Bestätigung) endet in
// AppBootstrapper.RestartApplication(), das Environment.Exit(0) aufruft - das darf ein Test
// niemals auslösen (würde den Testlauf beenden). Deshalb sind hier nur die Zweige getestet,
// die VOR diesem Aufruf zurückkehren; genau dasselbe Muster gilt bereits für
// RedeemPairingCode, das aus demselben Grund ebenfalls keinen Erfolgstest hat. Der
// eigentliche Kryptografie-/Datei-Roundtrip ist in SyncKeyRecoveryServiceTests abgedeckt.
[Fact]
public void SyncKeyWasRegenerated_SpiegeltDenBeimStartErkanntenStatusWider()
{
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: true));
Assert.True(vm.SyncKeyWasRegenerated);
}
[Fact]
public void SyncKeyWasRegenerated_NormalerStart_IstFalse()
{
var vm = BuildViewModel(syncKeyStatus: new SyncKeyStatus(keyWasRegenerated: false));
Assert.False(vm.SyncKeyWasRegenerated);
}
[Fact]
public async Task CreateRecoveryCode_SpeichernErfolgreich_ZeigtCodeUndUebergibtDateiinhalt()
{
var vm = BuildViewModel();
string? savedContent = null;
vm.OnSaveRecoveryFile = content => { savedContent = content; return Task.FromResult(true); };
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
Assert.NotEmpty(vm.RecoveryCode);
Assert.NotNull(savedContent);
Assert.Contains("notieren", vm.RecoveryStatus);
}
[Fact]
public async Task CreateRecoveryCode_AbbruchBeimSpeichern_ZeigtKeinenCode()
{
var vm = BuildViewModel();
vm.OnSaveRecoveryFile = _ => Task.FromResult(false);
await vm.CreateRecoveryCodeCommand.ExecuteAsync(null);
Assert.Empty(vm.RecoveryCode);
Assert.Equal("Abgebrochen.", vm.RecoveryStatus);
}
[Fact]
public async Task RedeemRecoveryCode_LeererCode_FragtNieNachEinerDatei()
{
var vm = BuildViewModel();
var filePickerCalled = false;
vm.OnPickRecoveryFile = () => { filePickerCalled = true; return Task.FromResult<string?>(null); };
vm.RecoveryCodeInput = " ";
await vm.RedeemRecoveryCodeCommand.ExecuteAsync(null);
Assert.False(filePickerCalled);
Assert.Equal("Bitte Code eingeben.", vm.RecoveryStatus);
}
[Fact]
public async Task RedeemRecoveryCode_DateiauswahlAbgebrochen_TutNichts()
{
var vm = BuildViewModel();
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(null);
vm.RecoveryCodeInput = "IRGENDEIN-CODE";
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
Assert.Null(exception);
}
[Fact]
public async Task RedeemRecoveryCode_FalscherCode_ZeigtFehlerUndWirftNicht()
{
var recovery = TestSupport.BuildSyncKeyRecoveryService();
var vm = BuildViewModel(syncKeyRecovery: recovery);
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("RICHTIGER-CODE"));
vm.OnConfirmRecoveryRestore = () => Task.FromResult(true);
vm.RecoveryCodeInput = "FALSCHER-CODE";
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
Assert.Null(exception);
Assert.Contains("passen nicht zusammen", vm.RecoveryStatus);
}
[Fact]
public async Task RedeemRecoveryCode_BestaetigungAbgelehnt_TutNichts()
{
var recovery = TestSupport.BuildSyncKeyRecoveryService();
var vm = BuildViewModel(syncKeyRecovery: recovery);
vm.OnPickRecoveryFile = () => Task.FromResult<string?>(recovery.CreateRecoveryFile("EIN-CODE"));
vm.OnConfirmRecoveryRestore = () => Task.FromResult(false);
vm.RecoveryCodeInput = "EIN-CODE";
var exception = await Record.ExceptionAsync(() => vm.RedeemRecoveryCodeCommand.ExecuteAsync(null));
Assert.Null(exception);
}
[Fact]
@@ -132,6 +277,34 @@ public sealed class SettingsViewModelTests
Assert.Empty(vm.SchoolHolidayEntries);
}
[Fact]
public async Task SaveSchoolLocation_FehlendeFelder_ZeigtAlleFeldfehler()
{
var vm = BuildViewModel();
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
Assert.NotEmpty(vm.SchoolNameError);
Assert.NotEmpty(vm.SchoolStreetError);
Assert.NotEmpty(vm.SchoolPostalCodeError);
Assert.NotEmpty(vm.SchoolCityError);
}
[Fact]
public async Task SaveSchoolLocation_UngueltigePlz_RuftKeinenServerAuf()
{
var vm = BuildViewModel();
vm.SchoolName = "Testschule";
vm.SchoolStreet = "Schulweg 1";
vm.SchoolPostalCode = "1234";
vm.SchoolCity = "Teststadt";
await vm.SaveSchoolLocationCommand.ExecuteAsync(null);
Assert.Contains("fünfstellige", vm.SchoolPostalCodeError);
Assert.Empty(vm.SchoolLocationStatus);
}
[Fact]
public void SelectedStateName_Aendern_PersistiertUeberSchoolCalendarSettings()
{
@@ -143,13 +316,17 @@ 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), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.SelectedStateName = "Bayern";
@@ -167,13 +344,17 @@ 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), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45";
@@ -195,13 +376,17 @@ 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), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger());
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.PeriodTimes[0].StartText = "08:45";
vm.PeriodTimes[0].EndText = "08:00";
@@ -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,33 @@
using System.Net;
using LehrerApp.Desktop.Services;
using LehrerApp.Sync;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SyncAuthServiceProtocolTests
{
[Fact]
public async Task TestConnectionAsync_InkompatibleVersion_WirdNichtAlsOfflineGemeldet()
{
HttpRequestMessage? sent = null;
var service = new SyncAuthService(new HttpClient(new Handler(request =>
{
sent = request;
return new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
})));
var result = await service.TestConnectionAsync("https://sync.example.invalid", "token");
Assert.Equal(SyncConnectionTestResult.IncompatibleVersion, result);
Assert.Equal(SyncProtocol.CurrentVersion,
sent!.Headers.GetValues(SyncProtocol.VersionHeaderName).Single());
}
private sealed class Handler(Func<HttpRequestMessage, HttpResponseMessage> response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(response(request));
}
}
@@ -67,6 +67,19 @@ public sealed class SyncStatusViewModelTests
Assert.False(fired);
}
[Fact]
public async Task InkompatibleSyncVersion_ZeigtKlareUpdateMeldung()
{
using var temp = new TempSyncEngine(new IncompatibleVersionStubHandler());
var vm = new SyncStatusViewModel(temp.Engine);
await temp.Engine.SyncNowAsync();
Assert.True(vm.IsIncompatibleVersion);
Assert.Equal("Update erforderlich", vm.StatusText);
Assert.Contains("Bitte aktualisiere die LehrerApp", SyncStatusViewModel.IncompatibleVersionMessage);
}
private sealed class PullEventStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
@@ -100,6 +113,17 @@ public sealed class SyncStatusViewModelTests
});
}
private sealed class IncompatibleVersionStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = new HttpResponseMessage(HttpStatusCode.UpgradeRequired);
response.Headers.Add(SyncProtocol.VersionHeaderName, "2");
return Task.FromResult(response);
}
}
private sealed class TempSyncEngine : IDisposable
{
private readonly string _queuePath = Path.Combine(
@@ -0,0 +1,203 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TeachingModeViewModelTests
{
private static SeatingPlanTabViewModel BuildSeatingPlan(Guid groupId, FakeSessions? sessions = null,
List<Student>? students = null, List<GroupMembership>? memberships = null,
List<SeatingPlan>? plans = null) =>
new(new FakeSeatingPlans(plans ?? [new SeatingPlan { GroupId = groupId, Name = "Standard", Rows = 1, Columns = 1 }]),
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()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{
GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox",
Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Activity = "Impuls" }],
};
var seatingPlan = BuildSeatingPlan(group.Id);
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);
Assert.Single(vm.LessonInfo.PhaseGroups.Single().Phases);
Assert.True(vm.SeatingPlan.HasSelectedPlan);
}
[Fact]
public void Konstruktor_VerknuepftDieSitzungMitDieserStunde()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var sessions = new FakeSessions([]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
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()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var existing = new ParticipationSession
{ GroupId = group.Id, Date = lesson.Date, LessonId = lesson.Id, Comment = "Redox" };
var sessions = new FakeSessions([existing]);
var seatingPlan = BuildSeatingPlan(group.Id, sessions);
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);
}
/// Eine archivierte Gruppe darf im Unterrichtsmodus nicht bearbeitbar sein - genau wie in der
/// normalen Gruppenansicht (GroupDetailViewModel.IsReadOnly).
[Fact]
public void Konstruktor_ArchivierteGruppe_InitialisiertSitzplanSchreibgeschuetzt()
{
var group = new LearningGroup { Name = "Q1 Chemie", IsActive = false };
var lesson = new Lesson
{ GroupId = group.Id, Date = DateOnly.FromDateTime(DateTime.Today), Topic = "Redox" };
var seatingPlan = BuildSeatingPlan(group.Id);
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);
}
}
@@ -1,5 +1,6 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
@@ -12,10 +13,12 @@ public sealed class TimetableViewModelTests
FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null,
FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null,
SchoolCalendarSettingsService? calendarSettings = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null)
FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = 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);
@@ -25,7 +28,9 @@ public sealed class TimetableViewModelTests
holidays ?? new FakeSchoolHolidays(),
calendarSettings ?? new SchoolCalendarSettingsService(tempPath),
new PublicHolidayService(), new SchoolYearService(),
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries());
supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(),
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 —
@@ -230,6 +235,207 @@ public sealed class TimetableViewModelTests
Assert.Equal(group.Id, navigatedTo);
}
// ── Direktsprung in den Verlaufsplan-Viewer (4.5.2) ──────────────────────────
[Fact]
public async Task OpenTodayLesson_MitVorhandenerLesson_OeffnetDenViewerStattZurGruppeZuNavigieren()
{
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 = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
Lesson? openedLesson = null;
Guid? navigatedTo = null;
vm.OnOpenLessonViewer = l => { openedLesson = l; return Task.CompletedTask; };
vm.OnNavigateToGroup = id => navigatedTo = id;
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
Assert.Equal(lesson.Id, openedLesson?.Id);
Assert.Null(navigatedTo);
Assert.Equal("Verlaufsplan ansehen", vm.TodayItems[0].OpenButtonLabel);
}
[Fact]
public async Task OpenTodayLesson_OhneVorhandeneLesson_NavigiertWieBisherZurGruppe()
{
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 = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Guid? navigatedTo = null;
var viewerOpened = false;
vm.OnOpenLessonViewer = _ => { viewerOpened = true; return Task.CompletedTask; };
vm.OnNavigateToGroup = id => navigatedTo = id;
await vm.OpenTodayLessonCommand.ExecuteAsync(vm.TodayItems[0]);
Assert.Equal(group.Id, navigatedTo);
Assert.False(viewerOpened);
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) ────────────────────────────────────────────────
[Fact]
public async Task StartTeachingMode_MitVorhandenerLesson_OeffnetDenUnterrichtsmodus()
{
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 = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
Lesson? openedLesson = null;
vm.OnOpenTeachingMode = l => { openedLesson = l; return Task.CompletedTask; };
await vm.StartTeachingModeCommand.ExecuteAsync(vm.TodayItems[0]);
Assert.Equal(lesson.Id, openedLesson?.Id);
}
[Fact]
public async Task StartTeachingMode_OhneVorhandeneLesson_TutNichts()
{
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 = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
var opened = false;
vm.OnOpenTeachingMode = _ => { opened = true; return Task.CompletedTask; };
await vm.StartTeachingModeCommand.ExecuteAsync(vm.TodayItems[0]);
Assert.False(opened);
Assert.False(vm.TodayItems[0].HasLesson);
}
[Fact]
public async Task OpenWeekCell_MitVorhandenerLesson_OeffnetDenViewer()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Monday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var lesson = new Lesson { GroupId = group.Id, Date = date, LessonNumber = 1, Topic = "Redox" };
var lessons = new FakeLessons();
lessons.Add(lesson);
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
Lesson? openedLesson = null;
vm.OnOpenLessonViewer = l => { openedLesson = l; return Task.CompletedTask; };
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
await vm.OpenWeekCellCommand.ExecuteAsync(cell);
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()
{
@@ -506,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()
{
@@ -624,6 +855,31 @@ public sealed class TimetableViewModelTests
Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_ZusaetzlicheAufsichtInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" - dieselbe Anzeige-Infrastruktur wie beim Ausfall oben, hier für
// eine automatisch erkannte zusätzliche Aufsicht ohne reguläre SupervisionDuty.
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Monday).AddDays(7);
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Supervision, AfterPeriod = 1,
Description = "Automatisch über WebUntis-Abgleich erkannt: Zusätzliche Aufsicht laut WebUntis.",
});
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions);
Assert.DoesNotContain(vm.WeekItems, c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
vm.NextWeekCommand.Execute(null);
var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 1."));
var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First();
Assert.True(mondayCell.IsSubstitutionSupervision);
Assert.Contains("Zusätzliche Aufsicht", mondayCell.SupervisionLocation);
}
[Fact]
public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige()
{
@@ -827,6 +1083,38 @@ public sealed class TimetableViewModelTests
Assert.Equal("6a auf Klassenfahrt", cell.Topic);
}
[Fact]
public void Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation()
{
// Nutzer-Feedback: "Nächste Woche habe ich eine Stunde Ausfall [...] wäre es doch auch
// schön, wenn das im Stundenplan für die nächste Woche irgendwie erkenntlich ist" - genau
// der Weg, den ein automatisch von WebUntis erkannter Ausfall nimmt (derselbe
// SubstitutionEntry-Mechanismus wie bei Handeinträgen), hier erstmals mit WeekOffset != 0
// geprüft.
var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" };
var group = new LearningGroup { Name = "6a", SubjectId = subject.Id };
var dateNextWeek = DateInCurrentWeek(DayOfWeek.Thursday).AddDays(7);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Thursday, PeriodNumber = 3 });
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry
{
Date = dateNextWeek, Kind = SubstitutionKind.Cancelled, PeriodNumber = 3,
Description = "Automatisch über WebUntis-Abgleich erkannt.",
});
var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions);
// Aktuelle Woche: an dieser Stelle regulär, noch kein Ausfall sichtbar.
var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.False(currentWeekCell.IsCancelled);
vm.NextWeekCommand.Execute(null);
var nextWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3);
Assert.True(nextWeekCell.IsCancelled);
Assert.Equal("Automatisch über WebUntis-Abgleich erkannt.", nextWeekCell.Topic);
}
[Fact]
public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde()
{
@@ -938,4 +1226,119 @@ public sealed class TimetableViewModelTests
Assert.Single(vm.UpcomingExams);
}
// ── WebUntis-Abweichung ───────────────────────────────────────────────────
[Fact]
public void Load_UntisAbgleichNichtAktiviert_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var vm = BuildViewModel(slots, new FakeGroups([group]));
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_UntisAktivSlotOhneBestaetigteZuordnung_ZeigtAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisSettings: untisSettings);
Assert.True(vm.HasUntisMismatch);
Assert.Contains("1 Stundenplan-Eintrag", vm.UntisMismatchLabel);
}
[Fact]
public void Load_UntisAktivMitBestaetigterZuordnung_ZeigtKeineAbweichung()
{
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
Assert.False(vm.HasUntisMismatch);
}
[Fact]
public void Load_DoppelstundenZuordnungBestaetigtBeideTimetableSlots()
{
// Regression: "ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die
// Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" - eine WebUntis-Doppelstunde
// bestätigt beide zugehörigen TimetableSlots, nicht nur den ersten.
var group = new LearningGroup { Name = "10c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 });
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, PeriodNumber = 1, CoveredPeriods = [1, 2],
GroupId = group.Id, Confirmed = true,
});
var untisSettings = TestSupport.BuildWebUntisSettingsService();
untisSettings.SetIcalUrl("https://example.org/ical");
untisSettings.SetEnabled(true);
var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings);
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);
}
}
@@ -0,0 +1,99 @@
using LehrerApp.Core.Models;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class TrashViewModelTests
{
private static TrashViewModel BuildViewModel(LiteDbContext db) => new(
new TrashRepository(db), new GradeRepository(db), new WorkTaskRepository(db),
new TimeEntryRepository(db), new SeatingPlanRepository(db), new GradingKeyTemplateRepository(db));
[Fact]
public void Konstruktor_LaedtVorhandenePapierkorbEintraege()
{
using var db = new LiteDbContext(new MemoryStream());
var taskRepo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Elternbrief schreiben" };
taskRepo.Save(task);
taskRepo.Delete(task.Id);
var vm = BuildViewModel(db);
Assert.True(vm.HasItems);
var item = Assert.Single(vm.Items);
Assert.Equal("Aufgabe", item.TypeLabel);
Assert.Equal("Elternbrief schreiben", item.Summary);
}
[Fact]
public void LeererPapierkorb_HasItemsIstFalsch()
{
using var db = new LiteDbContext(new MemoryStream());
var vm = BuildViewModel(db);
Assert.False(vm.HasItems);
Assert.Empty(vm.Items);
}
[Fact]
public void RestoreCommand_StelltAufgabeWiederHerUndAktualisiertDieListe()
{
using var db = new LiteDbContext(new MemoryStream());
var taskRepo = new WorkTaskRepository(db);
var task = new WorkTask { Title = "Klausuren korrigieren" };
taskRepo.Save(task);
taskRepo.Delete(task.Id);
var vm = BuildViewModel(db);
var item = Assert.Single(vm.Items);
vm.RestoreCommand.Execute(item);
Assert.NotNull(db.Tasks.FindById(task.Id));
Assert.False(vm.HasItems);
Assert.Contains("Klausuren korrigieren", vm.Status);
}
[Fact]
public void RestoreCommand_MitNullTutNichts()
{
using var db = new LiteDbContext(new MemoryStream());
var vm = BuildViewModel(db);
vm.RestoreCommand.Execute(null);
Assert.Equal("", vm.Status);
}
[Theory]
[InlineData(nameof(Grade), "Note")]
[InlineData(nameof(WorkTask), "Aufgabe")]
[InlineData(nameof(TimeEntry), "Zeiteintrag")]
[InlineData(nameof(SeatingPlan), "Sitzplan")]
[InlineData(nameof(GradingKeyTemplate), "Notenschlüssel-Vorlage")]
public void TypeLabel_UebersetztDenEntityTypeInsDeutsche(string entityType, string expectedLabel)
{
using var db = new LiteDbContext(new MemoryStream());
db.TrashedItems.Insert(new TrashedItem { EntityType = entityType, Summary = "Testeintrag" });
var vm = BuildViewModel(db);
var item = Assert.Single(vm.Items);
Assert.Equal(expectedLabel, item.TypeLabel);
}
[Fact]
public void TypeLabel_UnbekannterEntityTypeFaelltAufDenRohenNamenZurueck()
{
using var db = new LiteDbContext(new MemoryStream());
db.TrashedItems.Insert(new TrashedItem { EntityType = "Sonstiges", Summary = "Testeintrag" });
var vm = BuildViewModel(db);
Assert.Equal("Sonstiges", Assert.Single(vm.Items).TypeLabel);
}
}
@@ -0,0 +1,183 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class UntisMappingReviewDialogViewModelTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untisreview-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
private static UntisSyncService BuildUntisSyncService(FakeUntisSlotMappings mappings) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
new FakeUntisSnapshots(), mappings, new FakeSubstitutionEntries(), new FakeGroups([]),
new FakeTimetableSlots(), new FakeSupervisionDuties(), new FakeSchoolHolidays(),
new PublicHolidayService(), new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static UntisMappingReviewDialogViewModel BuildDialogVm(FakeUntisSlotMappings mappings,
List<LearningGroup>? groups = null) =>
new(BuildUntisSyncService(mappings), mappings, groups ?? [], new FakeSubjects([]));
private static List<UntisGroupOption> Options(params LearningGroup[] groups) =>
groups.Select(g => new UntisGroupOption(g, null)).ToList();
private static UntisSlotMatch BuildLessonMatch(Guid? groupId, string classToken = "10c") => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20),
Summary = "SOL", ClassTokens = [classToken], OccurrenceCount = 40,
},
PeriodNumber = 1, SuggestedGroupId = groupId, IsConfident = groupId is not null,
};
private static UntisSlotMatch BuildSupervisionMatch() => new()
{
Pattern = new UntisWeeklyPattern
{
Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), EndTime = new TimeOnly(9, 40),
Summary = null, ClassTokens = [], OccurrenceCount = 40,
},
AfterPeriod = 2,
};
[Fact]
public void UntisMappingRow_VorschlagWirdVorausgewaehlt()
{
var group = new LearningGroup { Name = "10c" };
var row = new UntisMappingRow(BuildLessonMatch(group.Id), Options(group), existing: null);
Assert.Equal(group.Id, row.SelectedGroup?.Group.Id);
Assert.True(row.IsConfident);
Assert.False(row.IsSupervisionCandidate);
}
[Fact]
public void UntisMappingRow_OhneVorschlag_BleibtLeer()
{
var row = new UntisMappingRow(BuildLessonMatch(null), [], existing: null);
Assert.Null(row.SelectedGroup);
Assert.False(row.IsConfident);
}
[Fact]
public void UntisGroupOption_GleicherNameVerschiedeneFaecher_ZeigtFachImDisplayLabel()
{
// Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das Fach dabei, kann ich nicht
// sicher die richtige Lerngruppe hier auswählen."
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, "Chemie");
Assert.Equal("10c (Chemie)", option.DisplayLabel);
}
[Fact]
public void UntisGroupOption_OhneFach_ZeigtNurDenNamen()
{
var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, null);
Assert.Equal("10c", option.DisplayLabel);
}
[Fact]
public void UntisMappingRow_AufsichtsMuster_WirdAlsSupervisionCandidateErkanntUndKannAufgeloestWerden()
{
// Nutzer-Feedback: "Zwei Termine sind meine Aufsichten [...] vom Zeitraster und von der
// Dauer her könnten die erfasst werden" - kein Klassenbezug, aber AfterPeriod auflösbar.
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null);
Assert.True(row.IsSupervisionCandidate);
Assert.True(row.CanResolve);
Assert.False(row.ConfirmAsSupervision);
}
[Fact]
public void Save_SchreibtNurZeilenMitAusgewaehlterGruppeAlsBestaetigt()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(null, "9z"), Options(groupA, groupB), existing: null)); // ignoriert
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.True(saved.Confirmed);
Assert.Equal(SubstitutionKind.Lesson, saved.Kind);
Assert.Equal(groupA.Id, saved.GroupId);
Assert.Equal(1, saved.PeriodNumber);
}
[Fact]
public void Save_AufsichtBestaetigt_SchreibtSupervisionMapping()
{
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings);
var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null) { ConfirmAsSupervision = true };
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(SubstitutionKind.Supervision, saved.Kind);
Assert.Null(saved.GroupId);
Assert.Equal(2, saved.AfterPeriod);
Assert.True(saved.Confirmed);
}
[Fact]
public void Save_ErneutesSpeichernDerselbenZeile_AktualisiertStattDupliziert()
{
// Regression für "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" -
// wiederholtes Bestätigen desselben Slots darf keine zweite Zeile anlegen (sonst
// ArgumentException in UntisDiffService.Diff beim nächsten Poll, siehe dortigen Test).
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm1 = BuildDialogVm(mappings, [groupA, groupB]);
vm1.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null));
vm1.SaveCommand.Execute(null);
var firstSaved = Assert.Single(mappings.GetAll());
// Dialog erneut geöffnet - die bereits bestätigte Gruppe muss vorbefüllt sein.
var existing = mappings.GetAll().Single();
var row2 = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing);
Assert.Equal(groupA.Id, row2.SelectedGroup?.Group.Id);
row2.SelectedGroup = Options(groupB).Single(); // Nutzer korrigiert
var vm2 = BuildDialogVm(mappings, [groupA, groupB]);
vm2.Rows.Add(row2);
vm2.SaveCommand.Execute(null);
var saved = Assert.Single(mappings.GetAll());
Assert.Equal(firstSaved.Id, saved.Id);
Assert.Equal(groupB.Id, saved.GroupId);
}
[Fact]
public void Save_NutzerAendertVorschlag_UebernimmtNeueAuswahl()
{
var groupA = new LearningGroup { Name = "10c" };
var groupB = new LearningGroup { Name = "10d" };
var mappings = new FakeUntisSlotMappings();
var vm = BuildDialogVm(mappings, [groupA, groupB]);
var row = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null);
row.SelectedGroup = Options(groupB).Single();
vm.Rows.Add(row);
vm.SaveCommand.Execute(null);
Assert.Equal(groupB.Id, Assert.Single(mappings.GetAll()).GroupId);
}
}
@@ -0,0 +1,21 @@
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));
}
@@ -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();
}
}
}
@@ -0,0 +1,319 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den HTTP-freien Verarbeitungskern von UntisSyncService — kein echter Abruf nötig,
/// synthetischer ICS-Text nach dem realen WebUntis-Format (siehe Planungsdokument).
public sealed class UntisSyncServiceTests
{
private static string BuildIcs(string uid, string dtstart, string? summary, string description, string status = "CONFIRMED") =>
"BEGIN:VCALENDAR\nVERSION:2.0\n" +
"BEGIN:VEVENT\n" +
$"UID:{uid}\n" +
$"STATUS:{status}\n" +
$"DTSTART;TZID=Europe/Berlin:{dtstart}\n" +
$"DTEND;TZID=Europe/Berlin:{dtstart}\n" +
(summary is null ? "" : $"SUMMARY:{summary}\n") +
$"DESCRIPTION:{description}\n" +
"END:VEVENT\nEND:VCALENDAR\n";
private static string EmptyIcs() => "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n";
private static UntisSyncService BuildService(FakeUntisSnapshots? snapshots = null,
FakeUntisSlotMappings? mappings = null, FakeSubstitutionEntries? substitutions = null,
FakeGroups? groups = null, FakeTimetableSlots? timetableSlots = null,
FakeSupervisionDuties? supervisionDuties = null, FakeSchoolHolidays? schoolHolidays = null) => new(
new HttpClient(), new WebUntisSettingsService(BuildTempPath()),
snapshots ?? new FakeUntisSnapshots(), mappings ?? new FakeUntisSlotMappings(),
substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]),
timetableSlots ?? new FakeTimetableSlots(), supervisionDuties ?? new FakeSupervisionDuties(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(),
new SchoolCalendarSettingsService(BuildTempPath()),
new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService());
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untissync-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void ProcessIcsText_UnveraendertesEreignis_SchreibtKeineVertretungAberSnapshot()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var service = BuildService(snapshots: snapshots, mappings: mappings);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, result.EventCount);
Assert.Equal(0, result.SubstitutionCount);
Assert.Single(snapshots.GetAll());
}
[Fact]
public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions);
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Erneuter Poll mit demselben (noch immer abweichenden) Termin darf keinen zweiten
// Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, second.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Regression: "Es ist immer noch so [...] Immer die Kurse mit Lerngruppen, die aus mehreren
// Klassen zusammengesetzt sind" — nachdem der eigentliche Vergleichsfehler (ClassToken/
// Trennzeichen) behoben war, blieben bereits erzeugte falsche Vertretungen trotzdem stehen:
// ProcessIcsText schrieb bislang nur NEUE/weiterhin abweichende Kandidaten, räumte aber nie
// eine zuvor automatisch erzeugte Zeile ab, sobald der nächste Poll gar keine Abweichung mehr
// fand. Der Nutzer sah dadurch dieselbe (jetzt stale) Karteileiche, egal wie oft er neu
// zuordnete oder pollte.
[Fact]
public void ProcessIcsText_AbweichungLoestSichSpaeterAuf_EntferntDieZuvorErzeugteVertretung()
{
var mappings = new FakeUntisSlotMappings();
mappings.Add(new UntisSlotMapping
{
Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL",
ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true,
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Poll 1: Fach weicht (noch) ab - Vertretung wird angelegt.
var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(1, first.SubstitutionCount);
Assert.Single(substitutions.GetAll());
// Poll 2: derselbe Termin weicht jetzt nicht mehr ab (z.B. weil sich die WebUntis-Daten
// geändert haben oder ein Vergleichsfehler zwischenzeitlich behoben wurde) - die zuvor
// erzeugte Vertretung darf nicht als Karteileiche stehen bleiben.
var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(0, second.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
// ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht
// im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen
// Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates).
[Fact]
public void ProcessIcsText_VonAnfangAnFehlendeStunde_WirdAlsAusfallGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
// Kein Termin für missingDate im Feed (die 8c ist weg) - ein unbeteiligter, etwas
// späterer Termin belegt lediglich den Feed-Abdeckungshorizont darüber hinaus.
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Cancelled, entry.Kind);
Assert.Equal(3, entry.PeriodNumber);
}
[Fact]
public void ProcessIcsText_FehlendeStundeAnFerientag_WirdNichtGemeldet()
{
var missingDate = DateTime.Today.AddDays(3);
var mapping = new UntisSlotMapping
{
Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT",
ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday
{
StartDate = DateOnly.FromDateTime(missingDate), EndDate = DateOnly.FromDateTime(missingDate),
});
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, schoolHolidays: schoolHolidays);
var coverageDate = missingDate.AddDays(2);
var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_OhneBestaetigteZuordnung_SchreibtNieEineVertretung()
{
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void BuildMatchPreview_LiefertVorschlagFuerBekannteGruppe()
{
var group = new LearningGroup { Name = "10c" };
var service = BuildService(groups: new FakeGroups([group]));
var preview = service.BuildMatchPreview(BuildIcs("1", "20260817T075000", "SOL", "10c HED"));
Assert.Equal(1, preview.EventCount);
var match = Assert.Single(preview.Matches.Matches);
Assert.Equal(group.Id, match.SuggestedGroupId);
}
// ── Nutzer-Feedback: "Ich habe eine Aufsicht vertretungsweise bekommen [...] Die fehlt
// natürlich in der Woche drauf wieder. Wird dann bis zum Ende des gültigen Stundenplans diese
// Stunde als entfallene Aufsicht geführt?" — die "entfallen"-Meldung ist an die konkrete,
// tatsächlich gesehene Snapshot-Zeile eines Datums gekoppelt, nicht an eine dauerhaft
// erwartete wöchentliche Wiederholung: sobald sie einmal als entfallen gemeldet und aus dem
// Snapshot entfernt wurde, gibt es nichts mehr, das in einer Folgewoche erneut "verschwinden"
// könnte, solange WebUntis für diesen Slot keinen neuen Termin mehr listet. Mit einer
// passenden regulären SupervisionDuty, damit dieser Test ausschließlich den
// Verschwinden-Mechanismus prüft, unabhängig von der "Zusätzliche Aufsicht"-Erkennung unten.
[Fact]
public void ProcessIcsText_RegulaereAufsichtVerschwindetEinmalig_MeldetEntfallenNurEinmal()
{
var eventDate = DateTime.Today.AddDays(3); // innerhalb des 14-Tage-Lookaheads
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var snapshots = new FakeUntisSnapshots();
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
// Poll 1: Feed enthält die reguläre Aufsicht wie erwartet - keine Meldung nötig.
var first = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, first.SubstitutionCount);
Assert.Single(snapshots.GetAll());
// Poll 2: WebUntis listet an diesem Slot keinen Termin mehr (einmalig von jemand anderem
// übernommen). Genau EINE "entfallen"-Meldung, Snapshot-Zeile wird bereinigt.
var second = service.ProcessIcsText(EmptyIcs());
Assert.Equal(1, second.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Empty(snapshots.GetAll());
// Poll 3, 4, ...: weiterhin kein Termin an diesem Slot - keine erneute Meldung, "bis zum
// Ende des gültigen Stundenplans" bleibt es bei dem einen Eintrag.
var third = service.ProcessIcsText(EmptyIcs());
Assert.Equal(0, third.SubstitutionCount);
Assert.Single(substitutions.GetAll());
}
// ── Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der
// Sync nur so halb Sinn" — eine bestätigte Aufsichts-Zuordnung OHNE passende reguläre
// SupervisionDuty (Einstellungen "Aufsichten") ist selbst schon die meldenswerte Vertretung
// und soll deshalb sofort bei jedem Vorkommen als SubstitutionEntry sichtbar werden, nicht
// erst bei einer Abweichung davon.
[Fact]
public void ProcessIcsText_ZusaetzlicheAufsichtOhneRegulaereDuty_WirdSofortGemeldet()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(1, result.SubstitutionCount);
var entry = Assert.Single(substitutions.GetAll());
Assert.Equal(SubstitutionKind.Supervision, entry.Kind);
Assert.Equal(1, entry.AfterPeriod);
Assert.Contains("Zusätzliche Aufsicht", entry.Description);
// 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.Single(substitutions.GetAll());
}
[Fact]
public void ProcessIcsText_AufsichtMitRegulaererDuty_MeldetNichtsBeiNormalerAnwesenheit()
{
var eventDate = DateTime.Today.AddDays(3);
var dtstart = eventDate.ToString("yyyyMMdd") + "T093000";
var mapping = new UntisSlotMapping
{
Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30),
Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true,
};
var mappings = new FakeUntisSlotMappings();
mappings.Add(mapping);
var duties = new FakeSupervisionDuties();
duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" });
var substitutions = new FakeSubstitutionEntries();
var service = BuildService(mappings: mappings, substitutions: substitutions, supervisionDuties: duties);
var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED"));
Assert.Equal(0, result.SubstitutionCount);
Assert.Empty(substitutions.GetAll());
}
[Fact]
public void ConfirmMappings_SpeichertAlsBestaetigt()
{
var mappings = new FakeUntisSlotMappings();
var service = BuildService(mappings: mappings);
var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, GroupId = Guid.NewGuid() };
service.ConfirmMappings([mapping]);
Assert.True(Assert.Single(mappings.GetAll()).Confirmed);
}
}
@@ -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);
}
@@ -0,0 +1,124 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WebUntisSettingsServiceTests
{
private const string SampleUrl = "https://example.webuntis.com/WebUntis/ical_export?school=example&id=test&elemId=1&token=geheim";
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettingssvc-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void SetEnabled_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetEnabled(true);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.Enabled);
}
[Fact]
public void SetIcalUrl_IstVerschluesseltAbrufbar()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
Assert.True(service.IsConfigured);
Assert.Equal(SampleUrl, service.GetIcalUrl());
}
[Fact]
public void IcalUrl_UeberlebtNeueInstanzMitDemselbenPfad()
{
var path = BuildTempPath();
new WebUntisSettingsService(path).SetIcalUrl(SampleUrl);
var reloaded = new WebUntisSettingsService(path);
Assert.True(reloaded.IsConfigured);
Assert.Equal(SampleUrl, reloaded.GetIcalUrl());
}
[Fact]
public void KonfigurationsdateiEnthaeltNichtDasTokenImKlartext()
{
var path = BuildTempPath();
var service = new WebUntisSettingsService(path);
service.SetIcalUrl(SampleUrl);
var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json"));
Assert.DoesNotContain("token=geheim", raw);
}
[Fact]
public void ClearIcalUrl_EntferntUrlUndDeaktiviert()
{
var service = new WebUntisSettingsService(BuildTempPath());
service.SetIcalUrl(SampleUrl);
service.SetEnabled(true);
service.ClearIcalUrl();
Assert.False(service.IsConfigured);
Assert.Null(service.GetIcalUrl());
Assert.False(service.Enabled);
}
[Fact]
public void SetLastSync_PersistiertZeitstempelUndStatus()
{
var path = BuildTempPath();
var at = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc);
new WebUntisSettingsService(path).SetLastSync(at, "3 Vertretungen erkannt");
var reloaded = new WebUntisSettingsService(path);
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());
}
}
@@ -0,0 +1,65 @@
using LehrerApp.Desktop.Services;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class WindowSettingsServiceTests
{
private static string BuildTempPath()
{
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-windowsettings-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Load_OhneVorhandeneDatei_LiefertStandardgroesse()
{
var service = new WindowSettingsService(BuildTempPath());
var settings = service.Load();
Assert.Equal(1280, settings.Width);
Assert.Equal(800, settings.Height);
Assert.False(settings.IsMaximized);
}
[Fact]
public void SaveUndLoad_PersistiertUeberNeueInstanz()
{
var path = BuildTempPath();
new WindowSettingsService(path).Save(new WindowSettings { Width = 1600, Height = 950, IsMaximized = true });
var reloaded = new WindowSettingsService(path).Load();
Assert.Equal(1600, reloaded.Width);
Assert.Equal(950, reloaded.Height);
Assert.True(reloaded.IsMaximized);
}
/// Eine winzige oder negative Größe (z.B. durch eine beschädigte/manuell editierte Datei)
/// darf das Fenster beim nächsten Start nicht unbenutzbar klein machen.
[Fact]
public void Load_UnplausibelKleineGroesse_FaelltAufStandardgroesseZurueck()
{
var path = BuildTempPath();
new WindowSettingsService(path).Save(new WindowSettings { Width = 5, Height = 5 });
var reloaded = new WindowSettingsService(path).Load();
Assert.Equal(1280, reloaded.Width);
Assert.Equal(800, reloaded.Height);
}
[Fact]
public void Load_BeschaedigteDatei_FaelltAufStandardgroesseZurueck()
{
var path = BuildTempPath();
File.WriteAllText(Path.Combine(path, "windowsettings.json"), "{ kein json");
var settings = new WindowSettingsService(path).Load();
Assert.Equal(1280, settings.Width);
Assert.Equal(800, settings.Height);
}
}
@@ -171,7 +171,7 @@ public sealed class WorkTaskListViewModelTests
{
var tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
vm.OnEditTask = _ => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
vm.OnEditTask = (_, _) => Task.FromResult<WorkTask?>(new WorkTask { Title = "Neu" });
await vm.AddTaskCommand.ExecuteAsync(null);
@@ -179,6 +179,26 @@ public sealed class WorkTaskListViewModelTests
Assert.Equal("Neu", vm.Tasks[0].Title);
}
[Fact]
public async Task AddReminder_OeffnetDialogVorbelegtAlsErinnerungUndSpeichertErgebnis()
{
var tasks = new FakeWorkTasks();
var vm = new WorkTaskListViewModel(tasks, new FakeGroups([]), new FakeTimeEntries());
bool? passedStartAsReminder = null;
vm.OnEditTask = (_, startAsReminder) =>
{
passedStartAsReminder = startAsReminder;
return Task.FromResult<WorkTask?>(new WorkTask { Title = "Ansage machen", Kind = TaskKind.Reminder });
};
await vm.AddReminderCommand.ExecuteAsync(null);
Assert.True(passedStartAsReminder);
var item = Assert.Single(vm.Tasks);
Assert.True(item.IsReminder);
Assert.Equal("Erinnerung", item.CategoryLabel);
}
[Fact]
public void Tasks_SindNachFaelligkeitSortiertUnbefristeteAmEnde()
{
@@ -285,6 +305,122 @@ public sealed class AddEditWorkTaskDialogViewModelTests
Assert.NotNull(vm.Result);
Assert.Equal(TaskRecurrence.Monthly, vm.Result!.Recurrence);
}
[Fact]
public void Save_AlsErinnerung_SetztKindUndVerwirftGeschaetzteDauer()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "Ansage an die Klasse machen",
IsReminder = true,
EstimatedMinutesText = "90", // sollte ignoriert werden
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(TaskKind.Reminder, vm.Result!.Kind);
Assert.Null(vm.Result.EstimatedMinutes);
}
[Fact]
public void Konstruktor_StartAsReminder_SetztIsReminderVorbelegt()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [], startAsReminder: true);
Assert.True(vm.IsReminder);
}
[Fact]
public void Konstruktor_BestehendeErinnerung_ErkenntKindWieder()
{
var source = new WorkTask { Title = "Alt", Kind = TaskKind.Reminder };
var vm = new AddEditWorkTaskDialogViewModel(source, []);
Assert.True(vm.IsReminder);
}
[Fact]
public void Save_MitPrioritaet_UebernimmtSieInsResult()
{
var vm = new AddEditWorkTaskDialogViewModel(null, [])
{
Title = "Wichtig",
SelectedPriority = TaskPriorityDisplay.Label(TaskPriority.High),
};
vm.SaveCommand.Execute(null);
Assert.Equal(TaskPriority.High, vm.Result!.Priority);
}
[Fact]
public void Konstruktor_OhneQuelle_StartetMitNormalerPrioritaet()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []);
Assert.Equal(TaskPriorityDisplay.Label(TaskPriority.Normal), vm.SelectedPriority);
}
[Fact]
public void ChecklistItems_HinzufuegenUndEntfernen_WirdInResultUebernommen()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []) { Title = "Material einsammeln" };
vm.NewChecklistItemText = "Erste Position";
vm.AddChecklistItemCommand.Execute(null);
vm.NewChecklistItemText = "Zweite Position";
vm.AddChecklistItemCommand.Execute(null);
Assert.Equal(["Erste Position", "Zweite Position"], vm.ChecklistItems.Select(c => c.Label));
Assert.Equal("", vm.NewChecklistItemText);
vm.RemoveChecklistItemCommand.Execute(vm.ChecklistItems[0]);
vm.SaveCommand.Execute(null);
var item = Assert.Single(vm.Result!.ChecklistItems);
Assert.Equal("Zweite Position", item.Label);
Assert.Null(item.StudentId);
}
[Fact]
public void Konstruktor_BestehendeAufgabeMitChecklist_UebernimmtEintraege()
{
var source = new WorkTask
{
Title = "Alt",
ChecklistItems = [new ChecklistItem { Label = "Schon da", IsDone = true }],
};
var vm = new AddEditWorkTaskDialogViewModel(source, []);
var row = Assert.Single(vm.ChecklistItems);
Assert.Equal("Schon da", row.Label);
Assert.True(row.IsDone);
}
[Fact]
public void FillFromRoster_OhneGruppeOderLader_MachtNichts()
{
var vm = new AddEditWorkTaskDialogViewModel(null, []);
vm.FillFromRosterCommand.Execute(null);
Assert.Empty(vm.ChecklistItems);
}
[Fact]
public void FillFromRoster_BefuelltAusRosterUndUeberspringtDoppelteSchueler()
{
var group = new LearningGroup { Name = "8a" };
var studentId = Guid.NewGuid();
var otherStudentId = Guid.NewGuid();
var vm = new AddEditWorkTaskDialogViewModel(null, [group], loadRoster: _ =>
[(studentId, "Anna Beispiel"), (otherStudentId, "Ben Muster")])
{
SelectedGroup = group,
};
Assert.True(vm.CanFillFromRoster);
vm.FillFromRosterCommand.Execute(null);
Assert.Equal(2, vm.ChecklistItems.Count);
vm.FillFromRosterCommand.Execute(null);
Assert.Equal(2, vm.ChecklistItems.Count); // kein Duplikat beim zweiten Aufruf
}
}
public sealed class TimeTrackingViewModelTests
+31
View File
@@ -2,6 +2,25 @@
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>
<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 +35,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>
+136 -4
View File
@@ -1,6 +1,9 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Desktop.Services;
@@ -8,7 +11,10 @@ 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;
namespace LehrerApp.Desktop;
@@ -23,6 +29,13 @@ public class App : Application
public override void OnFrameworkInitializationCompleted()
{
// Vor jedem Fenster (auch dem DB-Passwort-Prompt) anwenden, damit kein kurzes Aufblitzen
// der Systemvorgabe zu sehen ist, falls Hell/Dunkel manuell erzwungen wurde. Eigenständige
// Instanz statt über den DI-Container (der erst in BuildServices() entsteht, die den
// Passwort-Prompt bereits verzögert) - dieselbe appData-Datei wie später der
// DI-Singleton, rein lesend also unproblematisch doppelt konstruiert.
ApplyTheme(new AppearanceSettingsService(AppBootstrapper.ResolveAppDataPath()).Load());
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank
@@ -56,6 +69,10 @@ public class App : Application
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));
if (!_exitHandlerAttached)
{
@@ -65,18 +82,42 @@ public class App : Application
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
// DI-Singletons werden erst beim ersten Auflösen erzeugt. Ohne dieses explizite
// Auflösen entstand der Timer des optionalen WebUntis-Abgleichs erst, wenn die
// Einstellungen geöffnet oder ein manueller Abruf gestartet wurde. Den Dienst beim
// Anwendungsstart aktivieren und wie den Jahresplan sofort einmal abgleichen; danach
// übernimmt sein stündlicher Timer.
if (Services.GetService<UntisSyncService>() is { } untisSync)
_ = untisSync.PollAsync();
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();
}
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
/// gesamten sichtbaren Baum automatisch neu, sobald sich <see cref="ThemeVariant"/>
/// ändert.</summary>
public static void ApplyTheme(AppTheme theme) =>
Current!.RequestedThemeVariant = theme switch
{
AppTheme.Light => ThemeVariant.Light,
AppTheme.Dark => ThemeVariant.Dark,
_ => ThemeVariant.Default,
};
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();
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
}
catch (Exception ex)
{
@@ -84,8 +125,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);
}
}
}
@@ -102,6 +154,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>();
@@ -122,4 +196,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);
}
}
+78 -4
View File
@@ -5,6 +5,8 @@ using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.Services;
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 +16,7 @@ using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
using LehrerApp.Templating;
namespace LehrerApp.Desktop;
@@ -105,19 +108,32 @@ public static class AppBootstrapper
ResolveDbPath();
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
// Muss VOR jedem möglichen Zugriff auf keyPath erfasst werden (siehe SyncKeyStatus unten) -
// File.Exists ist hier synchron und unabhängig von der Lazy-Auflösung der DI-Factories.
var keyExistedBefore = File.Exists(keyPath);
// ── Logging & Benachrichtigungen ─────────────────────────────────────────
EnsureLogger();
services.AddSingleton(Logger);
services.AddSingleton<NotificationService>();
services.AddSingleton<ExportService>();
services.AddSingleton<PdfExportService>();
services.AddSingleton<ITemplateLoader, TemplateLoader>();
services.AddSingleton<ITemplateRenderer, QuestTemplateRenderer>();
services.AddSingleton(_ => new TemplateStore(appData));
// ── 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() };
var backupPath = backup.CreateBackup(DbPath);
// 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));
@@ -135,6 +151,7 @@ public static class AppBootstrapper
services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
services.AddSingleton<ITrashRepository, TrashRepository>();
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
@@ -160,6 +177,13 @@ public static class AppBootstrapper
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
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 ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -171,7 +195,8 @@ public static class AppBootstrapper
services.AddSingleton(_ => new PeriodScheduleService(appData));
services.AddSingleton(_ => new WorkloadSettingsService(appData));
services.AddSingleton(_ => new DashboardSettingsService(appData));
services.AddSingleton(_ => new LetterTemplateService(appData));
services.AddSingleton(_ => new WindowSettingsService(appData));
services.AddSingleton(_ => new AppearanceSettingsService(appData));
services.AddSingleton<PlanningExchangeService>();
// ── KI-Unterstützung (4.5.9, optional nur wenn in den Einstellungen aktiviert) ──────
@@ -179,10 +204,48 @@ public static class AppBootstrapper
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
services.AddSingleton<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
services.AddSingleton(annualPlanSettings);
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new AnnualPlanSyncService(
new HttpClient(), annualPlanSettings,
sp.GetRequiredService<IAnnualPlanEventRepository>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData);
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>();
// 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.
// Bei einem Gerät, das noch nie eingeloggt war, ist ein fehlender Schlüssel dagegen der
// normale Erstlauf. Siehe SettingsViewModel (Warnbanner) und SyncKeyRecoveryService.
services.AddSingleton(new SyncKeyStatus(!keyExistedBefore && syncSettings.IsLoggedIn));
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
@@ -192,6 +255,9 @@ public static class AppBootstrapper
SyncCrypto.SaveKey(key, keyPath);
return key;
});
// Rein lokal/offline (kein Server-Zugriff nötig), deshalb unconditional registriert - im
// Unterschied zu SnapshotService unten, das eine konfigurierte Server-URL voraussetzt.
services.AddSingleton(sp => new SyncKeyRecoveryService(sp.GetRequiredService<byte[]>(), keyPath));
var serverUrl = syncSettings.ServerUrl;
var deviceId = LoadOrCreateDeviceId(appData);
@@ -235,9 +301,12 @@ 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(sp.GetService<SyncEngine>()));
new SyncStatusViewModel(
sp.GetService<SyncEngine>(),
sp.GetRequiredService<NotificationService>()));
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
services.AddSingleton<TimetableViewModel>();
@@ -245,10 +314,14 @@ public static class AppBootstrapper
services.AddSingleton<TimeTrackingViewModel>();
services.AddSingleton<WorkloadEvaluationViewModel>();
services.AddSingleton<WorkloadViewModel>();
services.AddSingleton<ClassTeacherDetailsViewModel>();
services.AddSingleton<ClassTeacherOverviewViewModel>();
services.AddSingleton<ExamsOverviewViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddTransient<GroupOverviewViewModel>();
services.AddTransient<ParticipationTabViewModel>();
services.AddTransient<GradeOverviewTabViewModel>();
services.AddTransient<PlanningTabViewModel>();
@@ -256,6 +329,7 @@ public static class AppBootstrapper
services.AddTransient<SeatingPlanTabViewModel>();
services.AddTransient<GroupDocumentationTabViewModel>();
services.AddTransient<AddGroupDialogViewModel>();
services.AddTransient<TrashViewModel>();
services.AddTransient<SettingsViewModel>();
var provider = services.BuildServiceProvider();

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