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
admin 9106b4b0e1 Edit Stammdaten Mitarbeitsessions 2026-08-19 12:47:09 +02:00
admin b2c1b15167 Neue Abwensenheitsmodi plus Upgrade Sitzplan 2026-08-19 12:27:12 +02:00
admin 1731e5088e Anpassung am Sitzplan 2026-08-19 11:23:54 +02:00
adminandClaude Sonnet 5 f868b3a259 feat: sichtbare Seite nach eingehendem Sync automatisch neu laden
EventApplier schreibt eingehende Sync-Ereignisse absichtlich direkt auf die rohe LiteDB-Collection
(Ping-Pong-Vermeidung), benachrichtigt dabei aber kein ViewModel - eine per Sync empfangene neue
Stunde erschien bisher erst nach manuellem Neuladen (z.B. Tab-Wechsel).

Neues SyncEngine.DataChanged-Ereignis, gefeuert nach jedem Pull mit Ereignissen sowie bei einer
RemoteWon-Konfliktauflösung; SyncStatusViewModel reicht es durch. MainWindowViewModel abonniert es
und lädt ausschließlich die gerade sichtbare Seite über ihren eigenen, längst vorhandenen
Lade-Einstieg neu - keine Navigation weg von offenen Detailansichten, keine neue Lade-Logik, und
eine laufende Inline-Bearbeitung (Schülerdetail) wird nicht überschrieben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 00:18:56 +02:00
adminandClaude Sonnet 5 979511ab6a fix: Kontowechsel ließ Push dauerhaft mit 404 scheitern
Nach einem Login mit korrigierter Groß-/Kleinschreibung (siehe vorheriger Fix zur userId-
Stabilität) bezog sich die lokale Versionsverfolgung je Entität (BasedOnServerSeq-Cache) und der
Pull-Cursor weiterhin auf das alte Konto - ServerSeq-Werte sind aber nur innerhalb des Event-Logs
EINES Kontos gültig. Der Push wurde zu Recht abgelehnt, der Server kannte die Entität unter der
neuen userId aber gar nicht (404 beim Nachladen), und HandleRejectedAsync gab bei einem 404
bisher einfach auf, ohne den veralteten Cache-Eintrag zu bereinigen - derselbe Fehlschlag bei
jedem weiteren Sync-Versuch.

Dreiteiliger Fix: (1) ein 404 beim Nachladen löscht jetzt den stale Cache-Eintrag, sodass der
nächste Push die Entität korrekt als neu behandelt und selbstheilend durchgeht; (2) der
"Vollständigen Sync erzwingen"-Button setzt jetzt auch die Push-Versionsverfolgung zurück, nicht
nur den Pull-Cursor; (3) SyncLogin erkennt einen echten Kontowechsel künftig proaktiv anhand der
kanonischen userId aus der Server-Antwort und resettet automatisch, bevor der Folgefehler
überhaupt auftreten kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 00:05:24 +02:00
adminandClaude Sonnet 5 ba8786906a fix: Login-userId war nicht stabil gegenüber Groß-/Kleinschreibung
/api/auth/login mintete die JWT-userId bisher aus dem roh eingegebenen Benutzernamen. LiteDBs
Standard-Collation vergleicht den eindeutigen Index auf Username aber case-insensitive - ein
Login mit nur einmal abweichender Schreibweise auf einem zweiten Gerät authentifiziert
erfolgreich, mintet aber eine andere userId. Da EventStore.GetCol(userId) diese direkt als
Dateiname für den Server-seitigen Event-Speicher nutzt, entstanden zwei komplett getrennte
Datenbestände für ein und dasselbe, aus Nutzersicht einzige Konto - Ursache dafür, dass ein
zweites Gerät trotz "since=0" durchgängig 0 Ereignisse erhielt.

UserStore.VerifyPassword(bool) durch Authenticate(UserEntry?) ersetzt, das bei Erfolg den
kanonisch gespeicherten Nutzereintrag liefert; /api/auth/login mintet Token und userId daraus
statt aus der Roheingabe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:51:31 +02:00
adminandClaude Sonnet 5 e7e5faeba8 fix: eigener Push ließ Client seinen Pull-Cursor über fremde Ereignisse springen
SyncEngine.PushAsync setzte den lokalen Pull-Cursor bisher aus PushResponse.ServerSequenceNr -
dem globalen Zähler über alle Geräte nach dem eigenen Push, nicht dem tatsächlich zugestellten
Stand. War beim Server zu diesem Zeitpunkt bereits ein noch nicht abgeholtes Ereignis eines
anderen Geräts mit niedrigerer ServerSeq vorhanden, sprang der Cursor darüber hinweg und der
direkt folgende Pull bekam 0 Ereignisse, ohne es je angewendet zu haben - ein zweiter,
unabhängiger Cursor-Bug mit demselben Symptom wie der vorherige Wasserzeichen-Fix, diesmal
client- statt serverseitig.

Ergänzt außerdem einen "Vollständigen Sync erzwingen"-Button in den Sync-Einstellungen, damit
bereits durch diesen Bug zu weit vorgerückte Geräte ihren Fortschritt manuell zurücksetzen und
alle Ereignisse erneut laden können.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:38:46 +02:00
adminandClaude Sonnet 5 5bd6967421 fix: Pull-Wasserzeichen sprang über fremde Ereignisse + schärfere Push-Kollisionskontrolle
EventStore.Pull gab bisher den globalen ServerSeq-Höchststand als neuen Cursor zurück statt
den höchsten unter den tatsächlich gelieferten Ereignissen - hatte ein Gerät selbst kurz zuvor
etwas gepusht, sprang sein Pull-Cursor über noch nicht abgeholte Ereignisse anderer Geräte
hinweg und verpasste sie dauerhaft, ohne jeden Fehler.

Ersetzt außerdem die bisherige 30-Sekunden-Heuristik zur Konflikterkennung beim Push durch
exakte BasedOnServerSeq-Prüfung: jedes SyncEvent trägt die ServerSeq, auf der es aufbaut: der
Server lehnt ab, wenn der aktuelle Stand nicht mehr passt. Bei Ablehnung lädt der Client sofort
den neuen Server-Stand nach, löst den Konflikt nach der bestehenden Desktop-vs-Companion/
Timestamp-Politik auf und macht ihn immer in der Konflikt-Review-UI sichtbar, statt die
verworfene Änderung stillschweigend zu verlieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 23:25:04 +02:00
adminandClaude Sonnet 5 270c4d40cd feat: Erfolgs-Protokollierung für die gesamte Sync-Kette
SyncEventPublisher/SyncEngine/EventApplier protokollierten bisher
ausschließlich Fehlschläge - ein sauberes Log bewies nur "nichts ist
abgestürzt", nicht ob eine Änderung tatsächlich hoch-/heruntergeladen
wurde. Jetzt wird auch der Erfolgspfad geloggt: Einreihen in die Outbox
(mit SequenceNr), Push/Pull mit Anzahl und Entitätstypen sowie der vom
Server bestätigten ServerSequenceNr, und jedes tatsächlich angewendete
Ereignis. Damit lässt sich anhand der Log-Dateien beider Geräte
nachvollziehen, an welcher Stelle der Kette eine Änderung verloren geht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:20:33 +02:00
adminandClaude Sonnet 5 dea7da8cca fix: Rate-Limit hinter Reverse Proxy wirkte wie ein globales statt Pro-IP-Limit
RemoteIpAddress zeigte hinter Dokploy/Traefik ohne ForwardedHeaders-Middleware
für jede Anfrage auf dieselbe interne Proxy-IP - das Pro-IP-Limit (120/Minute)
wurde dadurch faktisch zu einem einzigen globalen Limit für die gesamte
Bereitstellung, geteilt von allen Geräten und Endpunkten zusammen.

ForwardedHeadersOptions (X-Forwarded-For/X-Forwarded-Proto) registriert,
KnownIPNetworks/KnownProxies geleert (Container nur über den Reverse Proxy
erreichbar). API-only-Fix, Server-Redeploy nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:09:59 +02:00
adminandClaude Sonnet 5 722b7f87ff fix: Sync blockierte dauerhaft und unsichtbar bei fehlerhaftem Ereignis
EventApplier.ApplyAsync fing bisher nur LiteException ab - jede andere
Ausnahme (z.B. eine fehlerhafte Entschlüsselung/Deserialisierung eines
einzelnen Ereignisses) fiel unbehandelt aus der Pull-Schleife in
SyncEngine.PullAsync heraus, bevor der Fortschritt (SetLastServerSeq)
gespeichert wurde. Der nächste Sync-Versuch lud denselben Batch erneut
und scheiterte am selben Ereignis wieder - ein dauerhaft blockierter
Sync, bei dem selbst bereits erfolgreich angewendete Ereignisse im
selben Batch nie als erledigt markiert wurden. Zusätzlich protokollierte
weder SyncEngine noch EventApplier irgendetwas, ein Fehlschlag zeigte
sich höchstens als knapper Text in der Sync-Statusleiste.

EventApplier fängt jetzt jede Ausnahme pro Ereignis ab (geloggt über
AppLogger, übersprungen statt den Batch zu blockieren); SyncEngine
loggt jeden Sync-Fehlschlag vollständig. Betrifft nur den Desktop-Client,
kein API-Redeploy nötig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:53:30 +02:00
adminandClaude Sonnet 5 dc21cb2319 fix: Geräte-Pairing - Schlüssel-Code stimmte nie mit dem angezeigten Code überein
SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch: Schritt 1
holt einen Code vom Server, Schritt 2 verschlüsselt den Sync-Schlüssel mit
diesem Code und lädt erneut hoch. SnapshotStore.Store() vergab bei jedem
Aufruf bedingungslos einen neuen Zufallscode - der dem Nutzer am Ende
angezeigte Code war dadurch nie derselbe, mit dem der Schlüssel tatsächlich
verschlüsselt wurde. Jede Kopplung musste deterministisch an der
Schlüssel-Entschlüsselung scheitern.

SnapshotUploadRequest bekommt ein optionales Code-Feld; Store() aktualisiert
bei vorhandenem, passendem Code denselben Eintrag statt einen neuen mit
neuem Code anzulegen. Betrifft LehrerApp.Api - der Server muss neu deployt
werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:34:43 +02:00
admin 26461bd2f2 Add shared export infrastructure 2026-08-18 21:21:56 +02:00
adminandClaude Sonnet 5 41bf2c0756 feat: Stundenplan-Badge für unkontrollierte Hausaufgaben (4.5.4)
Lesson bekommt HomeworkChecked/HomeworkCheckDismissed. Der Stundenplan
(Tagesliste und Wochenraster im "Heute"-Tab) zeigt ein 📓-Badge, wenn die
letzte Stunde einer Gruppe eine Hausaufgabe hatte, die noch nicht
kontrolliert oder bewusst übersprungen wurde. Umschaltbar über zwei sich
gegenseitig ausschließende Checkboxen ("Kontrolliert"/"Nicht
kontrollieren") im Verlaufsplan-Editor, direkt neben dem
Hausaufgabenfeld.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 20:52:22 +02:00
admin 462080ab92 Fix null phase data in timetable overview 2026-08-18 20:31:12 +02:00
adminandClaude Sonnet 5 a0f44f1b25 feat: Abwesenheits-Hinweise, Fehlquote bei Zeugnisnoten, Gruppen-Dokumentation
- Schnellbewerten-Dialog: abwesende Schüler werden gedimmt und mit ihrem
  Anwesenheitsstatus statt der Aspektbeschriftung angezeigt, damit keine
  Mitarbeitsnote für nicht anwesende Schüler vergeben wird.
- Zeugnisnoten-Dialog: zeigt je Schüler die Fehlquote im gewählten
  Zeitraum, ab 50 % hervorgehoben (informativ, keine automatische
  Notenänderung).
- Gruppen-Tab "Dokumentation" (bisher Platzhalter) implementiert: listet
  alle Dokumentationseinträge der Gruppen-Schüler, mit Schüler-Filter und
  optionalem "Nur dieser Unterricht"-Schalter. Einträge aus anderen
  Lerngruppen werden standardmäßig mitangezeigt, aber gedimmt. Der
  Dokumentationsdialog bekommt dafür einen optionalen Schüler-Picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 19:45:01 +02:00
adminandClaude Sonnet 5 f2cb3c98c6 fix: Geräte-Pairing - Fehlschlag beim Einlösen war unsichtbar
RedeemPairingCode() rief nach einem Fehlschlag sofort RestartApplication()
auf, direkt nach dem Setzen von PairingStatus - die Fehlermeldung konnte
nie gerendert werden, und wurde auch nirgends geloggt. Ein Fehlschlag
(falscher/abgelaufener Code, Server nicht erreichbar) wirkte dadurch wie
ein kommentarloser Absturz ohne jede Spur.

- SettingsViewModel loggt den Fehler jetzt über AppLogger und zeigt vor
  dem Neustart einen Bestätigungsdialog mit der echten Fehlermeldung an.
- AppBootstrapper.RestartApplication() gibt unter dotnet run/IDE-Debug
  (wo ProcessPath auf den dotnet-Host statt die App zeigt) die
  ursprünglichen Kommandozeilenargumente beim Neustart mit, statt nur
  die dotnet-CLI-Hilfe zu öffnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 16:35:11 +02:00
admin d59d658bba Mac Build System und App Packing 2026-08-18 11:31:14 +02:00
adminandClaude Sonnet 5 fd509fc921 feat: Komfort-Features für Unterrichtsplanung, Zeiterfassung und Dashboard
- Neue-Stunde-Dialog: Datum wird beim Anlegen anhand des Stundenplans
  und der letzten Stunde der Einheit vorbelegt statt auf "heute"
  (behebt eine falsch erkannte Doppelstunde, wenn "heute" nicht auf
  den passenden Wochentag fiel).
- Zeiterfassung: Button "Unterrichtszeit heute übernehmen" schlägt
  Start/Ende aus dem heutigen Stundenplan inkl. Puffer davor/danach vor.
- Dashboard: neue Kachel "Ungeplante Stunden" erinnert an Stunden ohne
  Thema für heute/morgen, mit Opt-out je Gruppe ("Benötigt
  Unterrichtsplanung"), Doppelstunden-Erkennung (keine doppelte Meldung
  für die zweite Periode) und Berücksichtigung von Stundenausfall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 11:30:50 +02:00
381 changed files with 41136 additions and 2875 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"
+1
View File
@@ -2,6 +2,7 @@
bin/
obj/
out/
build/
# ── .NET ──────────────────────────────────────────────────────────────────────
*.user
+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" />
+10
View File
@@ -0,0 +1,10 @@
using Xunit;
// LiteDB nutzt einen statischen, geteilten BsonMapper.Global für die Reflection-basierte
// Index-Auflösung (EnsureIndex mit Lambda-Ausdrücken). Bei paralleler Testausführung über
// mehrere Testklassen hinweg (xUnit-Standard) konkurrieren mehrere Threads beim erstmaligen
// Aufbau der Typ-Metadaten für verschiedene Modelle — das führt zu sporadischen
// "Member X not found on BsonMapper"-Fehlern, die mit dem eigentlichen Testinhalt nichts zu
// tun haben (gleiches Muster wie in LehrerApp.Data.Tests/LehrerApp.Desktop.Tests). Tests in
// diesem Projekt laufen deshalb sequenziell.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
+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();
}
}
+184
View File
@@ -0,0 +1,184 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class EventStoreTests
{
private static SyncEvent MakeEvent(string deviceId, string entityType = "Lesson", string operation = "Save",
string? entityId = null, long? basedOnServerSeq = null) => new()
{
DeviceId = deviceId, DeviceType = DeviceType.Desktop,
EntityType = entityType, EntityId = entityId ?? Guid.NewGuid().ToString(),
Operation = operation, Payload = "encrypted", BasedOnServerSeq = basedOnServerSeq,
};
[Fact]
public void Pull_KeineNeuenEreignisse_LiefertUnverändertesSinceAlsWatermark()
{
using var temp = new TempEventStore();
temp.Events.Push("user", [MakeEvent("device-a")]);
var result = temp.Events.Pull("user", since: 5, requestingDeviceId: "device-b");
Assert.Empty(result.Events);
Assert.Equal(5, result.ServerSequenceNr);
}
[Fact]
public void Pull_LiefertEreignisseAnderesGeraets_WatermarkEntsprichtHoechsterZurueckgegebenerServerSeq()
{
using var temp = new TempEventStore();
temp.Events.Push("user", [MakeEvent("device-a"), MakeEvent("device-a")]);
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
Assert.Equal(2, result.Events.Count);
Assert.Equal(2, result.ServerSequenceNr);
}
/// Regression: das genau beobachtete Symptom — Gerät A pusht erfolgreich (Server bestätigt),
/// Gerät B pullt und bekommt 0 Ereignisse zurück, obwohl Gerät A's Ereignisse eigentlich für
/// Gerät B bestimmt waren. Ursache: Pull() gab bisher IMMER den globalen Höchststand
/// (LastSeq, inkl. der Ereignisse des ANFRAGENDEN Geräts selbst) als neuen "since"-Cursor
/// zurück — SyncEngine.PullAsync übernimmt den 1:1. Hat das anfragende Gerät SELBST kurz vorher
/// etwas gepusht (dessen Ereignisse hier bewusst per "DeviceId != requestingDeviceId"
/// herausgefiltert werden), sprang der Cursor über die noch gar nicht abgeholten Ereignisse
/// eines ANDEREN Geräts hinweg, wenn die eigenen neuer waren — sie wurden dauerhaft verpasst.
[Fact]
public void Pull_AnfragendesGeraetHatSelbstNeuereEreignisseGepusht_UeberspringtFremdeEreignisseNicht()
{
using var temp = new TempEventStore();
// Gerät A pusht zuerst (ServerSeq 1) - das ist die Änderung, die Gerät B eigentlich abholen soll.
temp.Events.Push("user", [MakeEvent("device-a")]);
// Gerät B hat selbst (z.B. im selben Sync-Zyklus, Push läuft immer vor Pull) etwas Neueres
// gepusht (ServerSeq 2) - dieses Ereignis gehört B selbst und wird unten aus der Pull-Antwort
// herausgefiltert.
temp.Events.Push("user", [MakeEvent("device-b")]);
// Gerät B pullt mit einem "since" von VOR seinem eigenen Push (0) - Geräts A's Ereignis
// (ServerSeq 1) muss zurückkommen.
var result = temp.Events.Pull("user", since: 0, requestingDeviceId: "device-b");
var evt = Assert.Single(result.Events);
Assert.Equal("device-a", evt.DeviceId);
// Der Bug: hier stand vorher 2 (globaler Höchststand, inkl. Geräts B eigenem Ereignis) -
// ein zweiter Pull mit since=2 hätte Geräts A's Ereignis (ServerSeq 1) nie wieder gefunden.
Assert.Equal(1, result.ServerSequenceNr);
}
// ── Exakte Kollisionsprüfung (BasedOnServerSeq statt 30s-Heuristik) ─────────
[Fact]
public void Push_NeueEntitaetOhneBasedOnServerSeq_WirdAngenommen()
{
using var temp = new TempEventStore();
var result = temp.Events.Push("user", [MakeEvent("device-a", basedOnServerSeq: null)]);
Assert.Empty(result.ConflictingEventIds);
}
[Fact]
public void Push_ZweitesEreignisMitFalschemBasedOnServerSeq_WirdAbgelehnt()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]); // -> ServerSeq 1
// Gerät B kennt die Entität noch gar nicht (oder einen veralteten Stand) und behauptet fälschlich, sie sei neu.
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
var result = temp.Events.Push("user", [second]);
Assert.Contains(second.EventId, result.ConflictingEventIds);
}
[Fact]
public void Push_ZweitesEreignisMitKorrektemBasedOnServerSeq_WirdAngenommen()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var assignedSeq = first.AssignedServerSeqs.Values.Single(); // ServerSeq 1
var second = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: assignedSeq);
var result = temp.Events.Push("user", [second]);
Assert.Empty(result.ConflictingEventIds);
}
[Fact]
public void Push_AkzeptierteEreignisse_LiefernJeEventIdDieVergebeneServerSeq()
{
using var temp = new TempEventStore();
var evt1 = MakeEvent("device-a");
var evt2 = MakeEvent("device-a");
var result = temp.Events.Push("user", [evt1, evt2]);
Assert.Equal(2, result.AssignedServerSeqs.Count);
Assert.True(result.AssignedServerSeqs.ContainsKey(evt1.EventId));
Assert.True(result.AssignedServerSeqs.ContainsKey(evt2.EventId));
Assert.Equal([1L, 2L], result.AssignedServerSeqs.Values.OrderBy(v => v));
}
[Fact]
public void Push_AbgelehntesEreignis_TauchtNichtInAssignedServerSeqsAuf()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var rejected = MakeEvent("device-b", entityId: entityId, basedOnServerSeq: null);
var result = temp.Events.Push("user", [rejected]);
Assert.False(result.AssignedServerSeqs.ContainsKey(rejected.EventId));
}
// ── GetLatestForEntity (Sofort-Nachladen nach abgelehntem Push) ─────────────
[Fact]
public void GetLatestForEntity_UnbekannteEntitaet_LiefertNull()
{
using var temp = new TempEventStore();
var result = temp.Events.GetLatestForEntity("user", "Lesson", Guid.NewGuid().ToString());
Assert.Null(result);
}
[Fact]
public void GetLatestForEntity_LiefertDenAktuellstenStandDieserEntitaet()
{
using var temp = new TempEventStore();
var entityId = Guid.NewGuid().ToString();
var first = temp.Events.Push("user", [MakeEvent("device-a", entityId: entityId, basedOnServerSeq: null)]);
var seq1 = first.AssignedServerSeqs.Values.Single();
temp.Events.Push("user", [MakeEvent("device-b", entityId: entityId, basedOnServerSeq: seq1)]);
var result = temp.Events.GetLatestForEntity("user", "Lesson", entityId);
Assert.NotNull(result);
Assert.Equal("device-b", result!.DeviceId);
Assert.Equal(seq1 + 1, result.SequenceNr);
}
private sealed class TempEventStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-eventstore-{Guid.NewGuid():N}");
public EventStore Events { get; }
public TempEventStore()
{
Directory.CreateDirectory(_directory);
Events = new EventStore(_directory);
}
public void Dispose()
{
Events.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
}
}
}
@@ -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);
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Api.Tests;
public sealed class SnapshotStoreTests
{
[Fact]
public void Store_ZweiterAufrufOhneCode_ErzeugtNeuenCode()
{
using var temp = new TempSnapshotStore();
var first = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p1" });
var second = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "p2" });
Assert.NotEqual(first.Code, second.Code);
}
/// Regression: SnapshotService.CreateAndUploadAsync lädt in zwei Schritten hoch — Schritt 1
/// ohne EncryptedSyncKey (holt einen Code vom Server), Schritt 2 mit dem gerade erst mit
/// diesem Code verschlüsselten Schlüssel. Ohne den zurückgereichten Code erzeugte Store() bei
/// jedem Aufruf einen NEUEN Zufallscode — der an den Nutzer angezeigte Code (aus Schritt 2)
/// passte dann nie zu dem Code, mit dem der Schlüssel tatsächlich verschlüsselt wurde, und das
/// Einlösen auf dem zweiten Gerät schlug beim Schlüssel-Entschlüsseln immer fehl.
[Fact]
public void Store_ZweiterAufrufMitCodeDesErstenSchritts_AktualisiertDenselbenEintrag()
{
using var temp = new TempSnapshotStore();
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var step2 = temp.Store.Store("user1", new SnapshotUploadRequest
{
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
});
Assert.Equal(step1.Code, step2.Code);
var retrieved = temp.Store.Retrieve("user1", step2.Code);
Assert.NotNull(retrieved);
Assert.Equal("encrypted-key", retrieved!.EncryptedSyncKey);
}
[Fact]
public void Store_CodeGehoertZuAnderemNutzer_ErzeugtStattdessenNeuenEintrag()
{
using var temp = new TempSnapshotStore();
var step1 = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var result = temp.Store.Store("user2", new SnapshotUploadRequest
{
EncryptedPayload = "payload", EncryptedSyncKey = "encrypted-key", Code = step1.Code,
});
Assert.NotEqual(step1.Code, result.Code);
Assert.NotNull(temp.Store.Retrieve("user2", result.Code));
}
[Fact]
public void Retrieve_LoeschtDenEintragNachDemErstenAbruf()
{
using var temp = new TempSnapshotStore();
var uploaded = temp.Store.Store("user1", new SnapshotUploadRequest { EncryptedPayload = "payload" });
var first = temp.Store.Retrieve("user1", uploaded.Code);
var second = temp.Store.Retrieve("user1", uploaded.Code);
Assert.NotNull(first);
Assert.Null(second);
}
private sealed class TempSnapshotStore : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}");
public LehrerApp.Api.SnapshotStore Store { get; }
public TempSnapshotStore()
{
Directory.CreateDirectory(_directory);
Store = new LehrerApp.Api.SnapshotStore(_directory);
}
public void Dispose()
{
Store.Dispose();
if (Directory.Exists(_directory)) Directory.Delete(_directory, 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;
}
}
+27 -7
View File
@@ -12,7 +12,7 @@ public sealed class UserStoreTests
var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.True(created);
Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort"));
Assert.NotNull(temp.Store.Authenticate("sebastian", "einSicheresPasswort"));
}
[Fact]
@@ -27,20 +27,40 @@ public sealed class UserStoreTests
}
[Fact]
public void VerifyPassword_FalschesPasswort_GibtFalseZurueck()
public void Authenticate_FalschesPasswort_LiefertNull()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort"));
Assert.Null(temp.Store.Authenticate("sebastian", "falschesPasswort"));
}
[Fact]
public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck()
public void Authenticate_UnbekannterNutzer_LiefertNull()
{
using var temp = new TempUserStore();
Assert.False(temp.Store.VerifyPassword("unbekannt", "irgendwas"));
Assert.Null(temp.Store.Authenticate("unbekannt", "irgendwas"));
}
/// Regression: LiteDBs Standard-Collation vergleicht den eindeutigen Index auf Username
/// case-insensitive - ein Login mit abweichender Groß-/Kleinschreibung authentifiziert also
/// erfolgreich. Würde der Aufrufer (Endpoints./api/auth/login) daraufhin den roh eingegebenen
/// Namen statt des kanonisch gespeicherten als userId für den Server-seitigen Event-Speicher
/// verwenden, würde ein einziger andersartig getippter Login auf einem zweiten Gerät einen
/// komplett getrennten, nicht überlappenden Datenbestand erzeugen (siehe TODO 10.2.5 - genau
/// das vom Nutzer beobachtete Symptom: nur ein Konto im System, aber Client 2 erhielt trotz
/// since=0 keine Ereignisse).
[Fact]
public void Authenticate_AndereGrossKleinschreibung_LiefertDenKanonischGespeichertenNamen()
{
using var temp = new TempUserStore();
temp.Store.CreateUser("sebastian", "einSicheresPasswort");
var user = temp.Store.Authenticate("Sebastian", "einSicheresPasswort");
Assert.NotNull(user);
Assert.Equal("sebastian", user!.Username);
}
[Fact]
@@ -52,8 +72,8 @@ public sealed class UserStoreTests
var updated = temp.Store.SetPassword("sebastian", "neuesPasswort123");
Assert.True(updated);
Assert.True(temp.Store.VerifyPassword("sebastian", "neuesPasswort123"));
Assert.False(temp.Store.VerifyPassword("sebastian", "altesPasswort123"));
Assert.NotNull(temp.Store.Authenticate("sebastian", "neuesPasswort123"));
Assert.Null(temp.Store.Authenticate("sebastian", "altesPasswort123"));
}
[Fact]
+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);
}
+138 -3
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;
@@ -19,9 +20,15 @@ public static class Endpoints
{
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized();
if (!store.VerifyPassword(req.Username, req.Password))
return Results.Unauthorized();
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
var user = store.Authenticate(req.Username, req.Password);
// Der kanonisch gespeicherte Username aus UserStore.Authenticate (nicht req.Username!)
// wird als JWT-userId verwendet - LiteDBs case-insensitive Standard-Collation lässt
// einen Login mit abweichender Groß-/Kleinschreibung erfolgreich durch; würde man
// stattdessen req.Username übernehmen, würde jede andersartig getippte Anmeldung einen
// eigenen, komplett getrennten Server-seitigen Event-Speicher erzeugen (siehe
// UserStore.Authenticate).
if (user is null) return Results.Unauthorized();
return Results.Ok(new { token = Jwt(user.Username, secret), userId = user.Username });
}).RequireRateLimiting("login");
}
@@ -47,6 +54,17 @@ public static class Endpoints
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
});
// Für Clients, deren Push wegen eines neueren Server-Stands abgelehnt wurde (10.3.4-
// Nachtrag: exakte Kollisionsprüfung statt 30s-Heuristik) — sofortiges Nachladen des
// aktuellen Stands EINER Entität, statt auf den nächsten regulären Pull zu warten.
g.MapGet("/entity/{entityType}/{entityId}", (string entityType, string entityId,
ClaimsPrincipal user, EventStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var result = store.GetLatestForEntity(uid, entityType, entityId);
return result is null ? Results.NotFound() : Results.Ok(result);
});
}
// ── Anhänge (eigener Binärkanal, getrennt vom JSON-Ereigniskanal) ──────────
@@ -132,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)
+52 -8
View File
@@ -1,4 +1,5 @@
using LiteDB;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
@@ -11,37 +12,80 @@ public class EventStore(string dataPath) : IDisposable
private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new();
/// <summary>
/// Nimmt Ereignisse an, wenn ihre <see cref="SyncEvent.BasedOnServerSeq"/> exakt der aktuellen
/// ServerSeq der jeweiligen Entität entspricht (null == Entität hier noch nie gesehen, z.B.
/// Neuanlage) — echte optimistische Nebenläufigkeitskontrolle statt der früheren 30-Sekunden-
/// Heuristik ("hat ein anderes Gerät kürzlich dieselbe Entität angefasst"), die sowohl falsch-
/// positiv (zwei Geräte bearbeiten zufällig kurz hintereinander verschiedene Felder) als auch
/// falsch-negativ (echter Konflikt liegt außerhalb des 30s-Fensters) sein konnte.
/// Mehrere Ereignisse derselben Entität IM SELBEN Aufruf bauen bewusst aufeinander auf (das
/// zweite prüft gegen den vom ersten gerade neu vergebenen Stand) — der Client schickt ohnehin
/// nur noch das jüngste ausstehende Ereignis je Entität (siehe SyncEngine.PushAsync).
/// </summary>
public PushResponse Push(string userId, List<SyncEvent> events)
{
var col = GetCol(userId);
var seq = LastSeq(col);
var rejects = new List<Guid>();
var assigned = new Dictionary<Guid, long>();
foreach (var e in events.OrderBy(e => e.Timestamp))
{
var recent = col.FindOne(x =>
x.EntityType == e.EntityType && x.EntityId == e.EntityId &&
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30));
if (recent is not null) { rejects.Add(e.EventId); continue; }
var currentSeq = LatestForEntity(col, e.EntityType, e.EntityId)?.ServerSeq;
if (currentSeq != e.BasedOnServerSeq) { rejects.Add(e.EventId); continue; }
var newSeq = ++seq;
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
ClientSeq = e.SequenceNr, ServerSeq = ++seq,
ClientSeq = e.SequenceNr, ServerSeq = newSeq,
EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload });
assigned[e.EventId] = newSeq;
}
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects };
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects,
AssignedServerSeqs = assigned };
}
/// <summary>Aktuellstes Ereignis einer einzelnen Entität — für Clients, deren Push wegen eines
/// neueren Server-Stands abgelehnt wurde (siehe <see cref="Push"/>), um sofort den aktuellen
/// Stand nachzuladen, statt auf den nächsten regulären Pull zu warten.</summary>
public SyncEvent? GetLatestForEntity(string userId, string entityType, string entityId)
{
var col = GetCol(userId);
var e = LatestForEntity(col, entityType, entityId);
// SequenceNr trägt hier (wie bei Pull) die ServerSeq — dieselbe Konvention wie sonst im
// Sync-Protokoll: bei vom Server stammenden Ereignissen ist SequenceNr immer die ServerSeq.
return e is null ? null : new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp, SequenceNr = e.ServerSeq,
EntityType = e.EntityType, EntityId = e.EntityId, Operation = e.Operation,
Payload = e.Payload };
}
private static ServerEvent? LatestForEntity(ILiteCollection<ServerEvent> col, string entityType, string entityId) =>
col.Find(x => x.EntityType == entityType && x.EntityId == entityId)
.OrderByDescending(x => x.ServerSeq).FirstOrDefault();
public PullResponse Pull(string userId, long since, string requestingDeviceId)
{
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,
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
.ToList();
return new() { Events = events, ServerSequenceNr = LastSeq(col) };
// ServerSequenceNr MUSS die höchste ServerSeq unter den tatsächlich zurückgegebenen
// Ereignissen sein, NICHT der globale Höchststand (LastSeq(col)) — der schließt auch
// Ereignisse ANDERER Geräte ein, die z.B. gerade erst (nach dem obigen Find-Aufruf, aber
// vor dieser Zeile) eingetroffen sind, oder — der Bug, der hier tatsächlich beobachtet
// wurde — Ereignisse des anfragenden Geräts selbst, die oben bewusst per
// "DeviceId != requestingDeviceId" herausgefiltert wurden. SyncEngine.PullAsync übernimmt
// ServerSequenceNr 1:1 als neuen "since"-Cursor für den nächsten Pull; mit dem globalen
// Höchststand würde der Client seinen Cursor über Ereignisse hinweg vorrücken, die er nie
// erhalten hat, und sie dauerhaft verpassen — genau das vom Nutzer beobachtete Symptom
// (Push meldet Erfolg, Pull liefert 0 Ereignisse, obwohl welche ausstehen).
var newWatermark = events.Count > 0 ? events.Max(e => e.SequenceNr) : since;
return new() { Events = events, ServerSequenceNr = newWatermark };
}
private ILiteCollection<ServerEvent> GetCol(string userId)
+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; }
}
}
+34
View File
@@ -2,6 +2,7 @@ using System.Text;
using System.Threading.RateLimiting;
using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
@@ -25,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()
@@ -36,6 +39,19 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
});
builder.Services.AddAuthorization();
// Läuft hinter einem Reverse Proxy (Dokploy/Traefik) - ohne diese Weiterleitung sähe Kestrel für
// JEDE Anfrage dieselbe interne Proxy-IP als Connection.RemoteIpAddress, wodurch das unten
// definierte Pro-IP-Rate-Limit faktisch zu einem einzigen globalen Limit für die gesamte
// Bereitstellung würde (Bug, siehe TODO.md 10.2.2 Nachtrag). KnownNetworks/KnownProxies bewusst
// geleert: der Container ist nur über den Reverse Proxy erreichbar, nie direkt aus dem Internet,
// daher ist der unmittelbare Absender von X-Forwarded-For hier immer vertrauenswürdig.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein
// grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte.
builder.Services.AddRateLimiter(options =>
@@ -67,9 +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);
@@ -78,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();
}
}
+16
View File
@@ -19,6 +19,22 @@ public class SnapshotStore(string dataPath) : IDisposable
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
{
// Zweiter Upload-Schritt (Schlüssel nachreichen, mit dem im ersten Schritt vergebenen Code
// verschlüsselt): denselben Eintrag aktualisieren statt einen neuen mit neuem Code
// anzulegen — sonst würde der Schlüssel dauerhaft mit dem falschen Code verknüpft bleiben
// und die Entschlüsselung auf dem Empfängergerät schlägt fehl (Bug, siehe TODO.md 10.3.1).
if (!string.IsNullOrEmpty(req.Code))
{
var existing = Col.FindOne(e => e.UserId == userId && e.Code == req.Code.ToUpperInvariant());
if (existing is not null)
{
existing.EncryptedPayload = req.EncryptedPayload;
existing.EncryptedSyncKey = req.EncryptedSyncKey;
Col.Update(existing);
return new() { Code = existing.Code, ExpiresAt = existing.ExpiresAt };
}
}
Col.DeleteMany(e => e.UserId == userId);
var code = NewCode();
var entry = new SnapshotEntry { Code = code, UserId = userId,
@@ -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);
}
+15 -3
View File
@@ -32,16 +32,28 @@ public class UserStore(string dataPath) : IDisposable
return true;
}
public bool VerifyPassword(string username, string password)
/// <summary>
/// Prüft die Anmeldedaten und liefert bei Erfolg den GESPEICHERTEN Nutzereintrag zurück -
/// nicht bloß true/false. Wichtig: LiteDBs Standard-Collation vergleicht Strings (und damit
/// auch den eindeutigen Index auf Username) standardmäßig case-insensitive, d.h. ein Login mit
/// abweichender Groß-/Kleinschreibung authentifiziert erfolgreich. Würde der Aufrufer daraufhin
/// den roh eingegebenen Benutzernamen als userId für den Server-seitigen Event-Speicher
/// verwenden (siehe EventStore.GetCol), würde ein einziger andersartig getippter Login auf
/// einem zweiten Gerät einen komplett getrennten, nicht überlappenden Datenbestand erzeugen,
/// obwohl es sich aus Nutzersicht um dasselbe Konto handelt. Der kanonisch gespeicherte Name
/// aus diesem Rückgabewert stellt sicher, dass die userId unabhängig von der beim Login
/// eingegebenen Schreibweise stets identisch ist.
/// </summary>
public UserEntry? Authenticate(string username, string password)
{
var user = Col.FindOne(x => x.Username == username);
return user is not null && PasswordHasher.Verify(password, user.PasswordHash);
return user is not null && PasswordHasher.Verify(password, user.PasswordHash) ? user : null;
}
public void Dispose() => _db.Dispose();
}
internal class UserEntry
public class UserEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Username { get; set; } = "";
@@ -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,
}
+20
View File
@@ -13,6 +13,26 @@ public class LearningGroup
public bool IsActive { get; set; } = true;
public bool IsOwnClass { get; set; }
public bool IsDifferentiated { get; set; }
/// <summary>
/// Default true (alte Datensätze ohne dieses Feld verhalten sich damit unverändert wie bisher
/// - LiteDB liefert den C#-Default für fehlende Felder). Für Gruppen wie Klassenrat oder
/// Willkommenskreis, die keinen inhaltlichen Verlaufsplan brauchen, in den Stammdaten
/// 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;
}
+46
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
@@ -60,6 +77,11 @@ public enum AttendanceStatus
OtherSchoolEvent,
// Hinten angefügt, damit die numerischen Werte bereits gespeicherter LiteDB-Einträge stabil bleiben.
Present,
Late,
SignificantlyLate,
LeftDuringClass,
LearningIsland,
Suspended,
}
/// <summary>
@@ -155,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,
};
}
}
+21 -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; }
@@ -56,8 +56,18 @@ public class Lesson
public TimeOnly? StartTime { get; set; }
public List<LessonPhaseStep> Phases { get; set; } = [];
public string? Homework { get; set; }
/// Markiert, dass die hier eingetragene Hausaufgabe in einer Folgestunde besprochen/kontrolliert
/// wurde — treibt das Stundenplan-Badge "Hausaufgabe kontrollieren" (4.5.4).
public bool HomeworkChecked { get; set; }
/// Bewusstes Abschalten der Erinnerung, ohne eine tatsächliche Kontrolle vorzutäuschen (z.B.
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
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;
}
@@ -90,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
@@ -200,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>
+23
View File
@@ -12,7 +12,24 @@ public class SeatingPlan
public string Room { get; set; } = "";
public int Rows { get; set; } = 4;
public int Columns { get; set; } = 4;
/// <summary>
/// Zeigt die Tafel unterhalb des Sitzrasters an. Der Standardwert <c>false</c>
/// erhält für bestehende Pläne die bisherige Darstellung oberhalb des Rasters.
/// </summary>
public bool IsBoardAtBottom { get; set; }
/// <summary>
/// Zusätzlicher horizontaler Abstand nach jeder Spalte, in Pixeln. Der Eintrag mit Index 0
/// liegt zwischen der ersten und zweiten Spalte. Fehlende Einträge bedeuten keinen Abstand.
/// </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;
}
@@ -23,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; }
}
+51 -3
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();
@@ -103,6 +149,8 @@ public class TimeEntry
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
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}.");
}
@@ -25,10 +25,13 @@ public class AttendanceBalanceService
.Select(e => e.Status!.Value)
.ToList();
var present = relevant.Count(s => s == AttendanceStatus.Present);
var present = relevant.Count(s => s is AttendanceStatus.Present
or AttendanceStatus.Late or AttendanceStatus.SignificantlyLate);
var excused = relevant.Count(s => s == AttendanceStatus.Excused);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused or AttendanceStatus.Truant);
var schoolEvent = relevant.Count(s => s == AttendanceStatus.OtherSchoolEvent);
var unexcused = relevant.Count(s => s is AttendanceStatus.Unexcused
or AttendanceStatus.Truant or AttendanceStatus.LeftDuringClass);
var schoolEvent = relevant.Count(s => s is AttendanceStatus.OtherSchoolEvent
or AttendanceStatus.LearningIsland or AttendanceStatus.Suspended);
var pending = relevant.Count(s => s == AttendanceStatus.ExcusePending);
var total = relevant.Count;
+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", "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(
+310 -1
View File
@@ -267,6 +267,8 @@ public sealed class RepositoryTests
repo.Save(new SeatingPlan
{
GroupId = group.Id, Name = "Standard", Room = "Physik", Rows = 2, Columns = 3,
IsBoardAtBottom = true,
ColumnGapWidths = [0, 90],
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = ben.Id }],
});
@@ -274,7 +276,9 @@ public sealed class RepositoryTests
Assert.Equal(2, plans.Count);
Assert.Contains(plans, p => p.Room == "B204" && p.Assignments.Single().StudentId == anna.Id);
Assert.Contains(plans, p => p.Room == "Physik" && p.Assignments.Single().StudentId == ben.Id);
Assert.Contains(plans, p => p.Room == "Physik" && p.IsBoardAtBottom
&& p.ColumnGapWidths.SequenceEqual([0, 90])
&& p.Assignments.Single().StudentId == ben.Id);
}
[Fact]
@@ -299,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()
{
@@ -684,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]
@@ -879,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();
+178 -2
View File
@@ -107,6 +107,8 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
plan.Name = plan.Name?.Trim() ?? "";
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)
@@ -114,6 +116,12 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
plan.ColumnGapWidths = Enumerable.Range(0, Math.Max(0, plan.Columns - 1))
.Select(i => i < plan.ColumnGapWidths.Count && double.IsFinite(plan.ColumnGapWidths[i])
? Math.Clamp(plan.ColumnGapWidths[i], 0, 300)
: 0)
.ToList();
var duplicateName = db.SeatingPlans.Find(p => p.GroupId == plan.GroupId)
.FirstOrDefault(p => p.Id != plan.Id
&& string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)
@@ -134,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);
@@ -141,11 +161,18 @@ public class SeatingPlanRepository(LiteDbContext db) : ISeatingPlanRepository
public void Delete(Guid id)
{
if (db.SeatingPlans.FindById(id) is { } plan)
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
@@ -240,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
@@ -259,11 +293,18 @@ public class GradeRepository(LiteDbContext db) : IGradeRepository
}
public void Delete(Guid id)
{
if (db.Grades.FindById(id) is { } grade)
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
@@ -348,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);
}
@@ -393,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;
@@ -401,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
@@ -421,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
@@ -431,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);
@@ -681,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);
@@ -693,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) =>
@@ -744,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(
@@ -26,23 +44,212 @@ public sealed class DashboardViewModelTests
return new DashboardSettingsService(tempPath);
}
private static SchoolCalendarSettingsService NewCalendarSettings()
{
var tempPath = System.IO.Path.Combine(
System.IO.Path.GetTempPath(), $"lehrerapp-schoolcalendar-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new SchoolCalendarSettingsService(tempPath);
}
private static DashboardViewModel BuildVm(LearningGroup group, Lesson lesson,
FakeTimetableSlots? slots = null, PeriodScheduleService? periodSchedule = null,
FakeExams? exams = null, FakeResults? results = null, FakeGrades? grades = null,
FakeReportGrades? reportGrades = null, FakeMemberships? memberships = null,
FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null,
DashboardSettingsService? dashboardSettings = null)
DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null,
FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null,
FakeSessions? sessions = null, FakeEntries? entries = null,
FakeAnnualPlanEvents? annualPlanEvents = null, List<LearningGroup>? allGroups = null,
FakeTimeEntries? timeEntries = null)
{
var lessons = new FakeLessons();
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());
new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(),
schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(),
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]
@@ -212,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()
{
@@ -227,4 +543,215 @@ public sealed class DashboardViewModelTests
Assert.False(saved.Single(c => c.Key == "today").IsVisible);
Assert.True(saved.FindIndex(c => c.Key == "tasks") > 1);
}
// ── Ungeplante Stunden (Nutzer-Feedback) ──────────────────────────────────
[Fact]
public void UnplannedLessons_StundeplanSlotOhneLessonMitThema_ErscheintInDerListe()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
// "lesson" liegt bewusst weit in der Vergangenheit, damit sie nicht in den heute/morgen-
// Vorgriff fällt und den Test verfälscht.
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
var item = Assert.Single(vm.UnplannedLessons);
Assert.Equal(group.Id, item.GroupId);
Assert.Equal(1, item.PeriodNumber);
Assert.Equal("Heute", item.DateDisplay);
}
[Fact]
public void UnplannedLessons_LessonMitThemaVorhanden_ErscheintNicht()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
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 = "Redoxreaktionen" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_GruppeOhnePlanungsbedarf_WirdIgnoriert()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "Klassenrat", RequiresLessonPlanning = false };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var vm = BuildVm(group, pastLesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_Feiertag_WirdUebersprungen()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 1 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 1, Topic = "Alt" };
var schoolHolidays = new FakeSchoolHolidays();
schoolHolidays.Add(new SchoolHoliday { Name = "Ferien", StartDate = today, EndDate = today.AddDays(1) });
var vm = BuildVm(group, pastLesson, slots: slots, schoolHolidays: schoolHolidays);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeMitThemaAmErstenSlot_ZweiterSlotWirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "Redox" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Empty(vm.UnplannedLessons);
}
[Fact]
public void UnplannedLessons_DoppelstundeOhneThema_BeideSlotsWerdenGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 3 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 4 });
var lesson = new Lesson { GroupId = group.Id, Date = today, LessonNumber = 3, Topic = "" };
var vm = BuildVm(group, lesson, slots: slots);
Assert.Equal(2, vm.UnplannedLessons.Count);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 3);
Assert.Contains(vm.UnplannedLessons, i => i.PeriodNumber == 4);
}
[Fact]
public void UnplannedLessons_StundenausfallEingetragen_WirdNichtGemeldet()
{
var today = DateOnly.FromDateTime(DateTime.Today);
var group = new LearningGroup { Name = "9c" };
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 5 });
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = today.DayOfWeek, PeriodNumber = 6 });
var pastLesson = new Lesson { GroupId = group.Id, Date = today.AddYears(-1), LessonNumber = 5, Topic = "Alt" };
var substitutions = new FakeSubstitutionEntries();
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 5 });
substitutions.Add(new SubstitutionEntry { Date = today, Kind = SubstitutionKind.Cancelled, PeriodNumber = 6 });
var vm = BuildVm(group, pastLesson, slots: slots, substitutions: substitutions);
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()
{
@@ -187,6 +205,79 @@ public sealed class DocumentationDialogViewModelTests
Assert.NotEqual("", vm.AttachmentError);
}
[Fact]
public void OhneStudentOptions_CanPickStudentIstFalse()
{
var vm = new DocumentationDialogViewModel(Guid.NewGuid(), null, new FakeAttachmentStorage());
Assert.False(vm.CanPickStudent);
}
[Fact]
public void MitStudentOptions_OhneAuswahl_SaveSetztFehlerUndSpeichertNicht()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(), [anna])
{
Title = "Vorfall", TypeName = "Vorkommnis",
};
vm.SaveCommand.Execute(null);
Assert.Null(vm.Result);
Assert.NotEqual("", vm.StudentError);
}
[Fact]
public void MitStudentOptions_AuswahlGetroffen_SaveUebernimmtSchuelerUndGruppe()
{
var groupId = Guid.NewGuid();
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var vm = new DocumentationDialogViewModel(Guid.Empty, null, new FakeAttachmentStorage(),
[anna, ben], groupId)
{
Title = "Vorfall", TypeName = "Vorkommnis", SelectedStudent = ben,
};
vm.SaveCommand.Execute(null);
Assert.NotNull(vm.Result);
Assert.Equal(ben.Id, vm.Result!.StudentId);
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()
{
var anna = new StudentOption(Guid.NewGuid(), "Anna Beispiel");
var ben = new StudentOption(Guid.NewGuid(), "Ben Muster");
var existing = new Documentation { StudentId = ben.Id, Title = "Alt" };
var vm = new DocumentationDialogViewModel(ben.Id, existing, new FakeAttachmentStorage(), [anna, ben]);
Assert.Equal(ben, vm.SelectedStudent);
}
[Fact]
public void DiscardUnsavedAttachments_EntferntNurNieGespeicherteAnhaenge()
{
@@ -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);
}
}
@@ -0,0 +1,32 @@
using LehrerApp.Desktop.Services;
using System.Text;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class CsvBuilderTests
{
[Fact]
public void AddRow_MaskiertTrennzeichen_AnfuehrungszeichenUndZeilenumbrueche()
{
var csv = new CsvBuilder()
.AddRow("Normal", "A;B", "Zitat \"wichtig\"", "Zeile 1\nZeile 2")
.ToString();
Assert.Contains("Normal;\"A;B\"", csv);
Assert.Contains("\"Zitat \"\"wichtig\"\"\"", csv);
Assert.Contains("\"Zeile 1\nZeile 2\"", csv);
}
[Fact]
public void CsvExportfile_BereinigtDateinameUndErgaenztEndungUndUtf8Bom()
{
var export = ExportFile.Csv("Export", "Auswertung/Chemie", "Änderung");
var bytes = export.Content.ToArray();
var preamble = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble();
Assert.Equal("Auswertung_Chemie.csv", export.SuggestedFileName);
Assert.Equal(preamble, bytes[..preamble.Length]);
Assert.Equal("Änderung", Encoding.UTF8.GetString(bytes[preamble.Length..]));
}
}
+159
View File
@@ -1,7 +1,10 @@
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;
@@ -28,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()
{
@@ -47,6 +66,47 @@ public static class TestSupport
Directory.CreateDirectory(tempPath);
return new EventQueue(Path.Combine(tempPath, "queue.db"));
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static PeriodScheduleService BuildPeriodScheduleService()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-periodschedule-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempPath);
return new PeriodScheduleService(tempPath);
}
/// Analog zu <see cref="BuildAiSettingsService"/>, eigenes Temp-Verzeichnis je Aufruf.
public static AppLogger BuildAppLogger()
{
var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-applogger-tests-{Guid.NewGuid():N}");
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
@@ -93,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);
@@ -160,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
@@ -197,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
@@ -365,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
@@ -389,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,15 +18,18 @@ 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,
new FakeSessions([]), new FakeEntries(), new FakeAspects()));
new FakeSessions([]), new FakeEntries(), new FakeAspects()),
new GroupDocumentationTabViewModel(new FakeDocumentation(), students, groups));
vm.LoadGroup(group.Id);
vm.SelectedExam = vm.Exams.First(e => e.Id == exam.Id);
@@ -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);
}
}
@@ -0,0 +1,166 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für den bisher als Platzhalter existierenden Gruppen-Tab "Dokumentation" (Nutzer-Feedback):
/// listet Dokumentationseinträge aller Gruppen-Schüler, zeigt Einträge aus anderen Lerngruppen
/// standardmäßig mit an (optisch abgesetzt statt versteckt), mit optionalem Filter darauf.
public sealed class GroupDocumentationTabViewModelTests
{
private static GroupDocumentationTabViewModel BuildVm(
List<Student> students, List<LearningGroup> groups, FakeDocumentation? docs = null)
{
var vm = new GroupDocumentationTabViewModel(docs ?? new FakeDocumentation(),
new FakeStudents(students), new FakeGroups(groups));
return vm;
}
[Fact]
public void Initialize_ZeigtEintraegeAllerGruppenschuelerSortiertNachDatumAbsteigend()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Alt", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Neu", Date = new DateOnly(2025, 9, 10) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
Assert.Equal(2, vm.Entries.Count);
Assert.Equal("Neu", vm.Entries[0].Model.Title);
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()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Aus anderem Kurs", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.False(entry.IsOwnGroup);
Assert.Equal("Mathematik 9b", entry.OtherGroupLabel);
Assert.True(entry.ContentOpacity < 1.0);
}
[Fact]
public void Initialize_EintragOhneGruppe_GiltAlsEigen()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = null, Title = "Allgemein", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
var entry = Assert.Single(vm.Entries);
Assert.True(entry.IsOwnGroup);
}
[Fact]
public void OnlyThisGroup_Aktiviert_BlendetEintraegeAusAnderenGruppenAus()
{
var group = new LearningGroup { Name = "9c" };
var otherGroup = new LearningGroup { Name = "Mathematik 9b" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Eigen", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = anna.Id, GroupId = otherGroup.Id, Title = "Fremd", Date = new DateOnly(2025, 9, 2) });
var vm = BuildVm([anna], [group, otherGroup], docs);
vm.Initialize(group.Id);
vm.OnlyThisGroup = true;
var entry = Assert.Single(vm.Entries);
Assert.Equal("Eigen", entry.Model.Title);
}
[Fact]
public void SelectedStudentFilter_AufEinzelnenSchueler_FiltertListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var ben = new Student { FirstName = "Ben", LastName = "Muster" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Anna-Eintrag", Date = new DateOnly(2025, 9, 1) });
docs.Add(new Documentation { StudentId = ben.Id, GroupId = group.Id, Title = "Ben-Eintrag", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna, ben], [group], docs);
vm.Initialize(group.Id);
vm.SelectedStudentFilter = vm.StudentFilterOptions.Single(s => s.Id == anna.Id);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Anna-Eintrag", entry.Model.Title);
}
[Fact]
public async Task AddDocumentation_SpeichertErgebnisUndLaedtNeu()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnEditDocumentation = (groupId, options, editing) =>
{
Assert.Equal(group.Id, groupId);
Assert.Contains(options, o => o.Id == anna.Id);
Assert.Null(editing);
return Task.FromResult<Documentation?>(new Documentation
{ StudentId = anna.Id, GroupId = groupId, Title = "Neu", Date = new DateOnly(2025, 9, 1) });
};
await vm.AddDocumentationCommand.ExecuteAsync(null);
var entry = Assert.Single(vm.Entries);
Assert.Equal("Neu", entry.Model.Title);
}
[Fact]
public async Task DeleteDocumentation_NachBestaetigung_EntferntEintragAusListe()
{
var group = new LearningGroup { Name = "9c" };
var anna = new Student { FirstName = "Anna", LastName = "Beispiel" };
var docs = new FakeDocumentation();
docs.Add(new Documentation { StudentId = anna.Id, GroupId = group.Id, Title = "Weg", Date = new DateOnly(2025, 9, 1) });
var vm = BuildVm([anna], [group], docs);
vm.Initialize(group.Id);
vm.OnConfirmDeleteDocumentation = _ => Task.FromResult(true);
await vm.DeleteDocumentationCommand.ExecuteAsync(vm.Entries[0]);
Assert.Empty(vm.Entries);
}
}
@@ -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,111 @@ 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()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
Assert.False(vm.HasHomeworkText);
vm.Homework = " ";
Assert.False(vm.HasHomeworkText);
vm.Homework = "S. 42";
Assert.True(vm.HasHomeworkText);
}
[Fact]
public void Save_UebernimmtHomeworkCheckedUndDismissed()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
vm.Topic = "Brechung"; vm.DateText = "01.09.2025";
vm.Homework = "S. 42"; vm.HomeworkChecked = true;
vm.SaveCommand.Execute(null);
Assert.True(vm.Result!.HomeworkChecked);
Assert.False(vm.Result.HomeworkCheckDismissed);
}
[Fact]
public void HomeworkChecked_UndHomeworkCheckDismissed_SchliessenSichGegenseitigAus()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid());
vm.HomeworkChecked = true;
Assert.False(vm.HomeworkCheckDismissed);
vm.HomeworkCheckDismissed = true;
Assert.False(vm.HomeworkChecked);
}
[Fact]
public void Bearbeiten_UebernimmtGespeicherteHomeworkFlags()
{
var editing = new Lesson
{
Topic = "Alt", Date = new DateOnly(2025, 9, 1), Homework = "S. 42",
HomeworkCheckDismissed = true,
};
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid(), editing);
Assert.True(vm.HomeworkCheckDismissed);
Assert.False(vm.HomeworkChecked);
}
[Fact]
public void HasAlternativePath_AngehaktOeffnetZuweisungUndUebernimmtErgebnis()
{
@@ -252,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);
@@ -388,4 +494,91 @@ public sealed class LessonDialogViewModelTests
Assert.False(vm.HasTimeBudgetInfo);
}
// ── Datumsvorschlag für eine neue Stunde (Nutzer-Feedback: Feld stand sonst immer auf
// "heute", was die Doppelstunden-Erkennung stillschweigend auf eine Einzelperiode
// zurückfallen ließ, wenn heute zufällig nicht der Unterrichtstag ist) ────────────────────
[Fact]
public void NeueStunde_OhneStundenplanEintraege_BleibtBeimHeutigenDatum()
{
var vm = BuildVm(Guid.NewGuid(), Guid.NewGuid()); // FakeTimetableSlots leer
Assert.Equal(DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy"), vm.DateText);
}
[Fact]
public void NeueStunde_LetzteStundeInDerZukunft_SchlaegtNaechstenPassendenWochentagDanachVor()
{
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 = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
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]
public void NeueStunde_LetzteStundeInDerVergangenheit_SchlaegtNaechstenPassendenWochentagAbHeuteVor()
{
var unitId = Guid.NewGuid();
var groupId = Guid.NewGuid();
var lessons = new FakeLessons();
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = new DateOnly(2000, 1, 1), LessonNumber = 3 });
var weekday = DateTime.Today.DayOfWeek; // garantiert am selben oder folgenden Tag erreichbar
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = groupId, Weekday = weekday, PeriodNumber = 3 });
var vm = new LessonDialogViewModel(lessons, new FakeShorthandCodes([]), new FakeAlternativeLessonPaths([]),
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);
}
}
@@ -0,0 +1,107 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class ParticipationSessionEditingTests
{
[Fact]
public void Bearbeitungsdialog_AendertBestehendeSessionOhneVerknuepfungenZuErsetzen()
{
var session = new ParticipationSession
{
GroupId = Guid.NewGuid(),
Date = new DateOnly(2026, 8, 19),
Comment = "Sitzplan",
LessonId = Guid.NewGuid(),
CompetencyCodes = ["K1"],
};
var vm = new AddSessionDialogViewModel(session)
{
DateText = "20.08.2026",
Comment = "Elektrische Stromkreise",
};
vm.SaveCommand.Execute(null);
Assert.Same(session, vm.Result);
Assert.Equal(new DateOnly(2026, 8, 20), session.Date);
Assert.Equal("Elektrische Stromkreise", session.Comment);
Assert.NotNull(session.LessonId);
Assert.Equal(["K1"], session.CompetencyCodes);
}
[Fact]
public async Task BearbeitenBefehl_SpeichertSessionUndAktualisiertAnzeige()
{
var group = new LearningGroup { Name = "8a", SchoolYear = "2026/27" };
var session = new ParticipationSession
{
GroupId = group.Id,
Date = new DateOnly(2026, 8, 19),
Comment = "Sitzplan",
};
var sessions = new FakeSessions([session]);
var vm = new ParticipationTabViewModel(
sessions, new FakeEntries(), new FakeAspects(), new FakeStudents([]),
new FakeMemberships([]), new FakeGroups([group]), new FakeCompetencyDomains());
vm.OnEditSession = existing =>
{
existing.Comment = "Nachbesprechung Elektrizität";
return Task.FromResult<ParticipationSession?>(existing);
};
vm.Initialize(group.Id, group.SchoolYear);
await vm.EditSessionCommand.ExecuteAsync(null);
Assert.Equal(session.Id, vm.SelectedSession?.Id);
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);
}
}
@@ -6,6 +6,24 @@ namespace LehrerApp.Desktop.Tests;
public sealed class QuickInputViewModelTests
{
[Theory]
[InlineData(AttendanceStatus.Late, false, "Verspätet", "V")]
[InlineData(AttendanceStatus.SignificantlyLate, false, "Erheblich verspätet", "V!")]
[InlineData(AttendanceStatus.LeftDuringClass, true, "Während des Unterrichts abgängig", "A")]
[InlineData(AttendanceStatus.LearningIsland, true, "Lerninsel", "L")]
[InlineData(AttendanceStatus.Suspended, true, "Suspendiert", "S")]
public void ZusaetzlicheAnwesenheitsstatus_HabenAnzeigeUndPassendeAbwesenheitswertung(
AttendanceStatus status, bool isAbsent, string label, string symbol)
{
var row = new ParticipationStudentRow(Guid.NewGuid(), "Anna",
new ParticipationEntry { Attendance = status }, [], []);
Assert.Equal(isAbsent, row.IsAbsent);
Assert.Equal(label, row.AttendanceTooltip);
Assert.Equal(symbol, row.AttendanceLabel);
Assert.False(string.IsNullOrWhiteSpace(AttendanceDisplay.Color(status)));
}
[Fact]
public void ZurueckZuVorherigemSchueler_ZeigtBereitsGesetzteBewertung()
{
@@ -27,6 +45,105 @@ public sealed class QuickInputViewModelTests
Assert.Equal(1, vm.AspectRows[0].Value);
}
[Fact]
public void AnwesenderSchueler_CurrentStudentIsAbsent_IstFalse()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry { Attendance = AttendanceStatus.Present }, aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
Assert.False(vm.CurrentStudentIsAbsent);
Assert.Equal(1.0, vm.CurrentStudentContentOpacity);
}
[Fact]
public void AbwesenderSchueler_CurrentStudentIsAbsent_IstTrueUndGedimmt()
{
var aspects = new List<AspectColumnDef>
{
new(new ParticipationAspect { Key = "a", Label = "A" }),
};
var rows = new List<ParticipationStudentRow>
{
new(Guid.NewGuid(), "Anna", new ParticipationEntry(), aspects, []),
new(Guid.NewGuid(), "Ben", new ParticipationEntry { Attendance = AttendanceStatus.Unexcused }, aspects, []),
};
var vm = new QuickInputViewModel(rows, aspects);
vm.NextStudent(); // zu Ben, unentschuldigt abwesend
Assert.True(vm.CurrentStudentIsAbsent);
Assert.Equal("Krank, unentschuldigt", vm.CurrentStudentAttendanceLabel);
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()
{
@@ -15,14 +15,15 @@ public class ReportGradeCalculationTests
private static ReportGradeDialogViewModel BuildViewModel(
FakeGrades grades, FakeSchemes schemes, FakeReportGrades reportGrades,
FakeExams exams, FakeResults results)
FakeExams exams, FakeResults results, FakeSessions? sessions = null, FakeEntries? entries = null)
{
var students = new FakeStudents([Anna, Ben]);
var memberships = new FakeMemberships([]);
var grading = new GradingService();
return new ReportGradeDialogViewModel(grades, exams, results, students, memberships,
schemes, reportGrades, grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
schemes, reportGrades, sessions ?? new FakeSessions([]), entries ?? new FakeEntries(),
new AttendanceBalanceService(), grading, GroupId, GroupType.Class, GradingSystem.Grades1To6,
"Testgruppe", "2025/26");
}
@@ -68,6 +69,28 @@ public class ReportGradeCalculationTests
Assert.Equal("", ben.CalculatedDisplay);
}
[Fact]
public void AbsenceRatePercent_BerechnetFehlquoteAusSitzungenDieserGruppeImZeitraum()
{
var (_, _, exams, results) = BuildExams();
var session1 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 5) };
var session2 = new ParticipationSession { GroupId = GroupId, Date = new DateOnly(2025, 9, 12) };
var sessions = new FakeSessions([session1, session2]);
var entries = new FakeEntries();
entries.Add(new ParticipationEntry { SessionId = session1.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Present });
entries.Add(new ParticipationEntry { SessionId = session2.Id, StudentId = Anna.Id, Attendance = AttendanceStatus.Unexcused });
var vm = BuildViewModel(new FakeGrades(), new FakeSchemes(), new FakeReportGrades(), exams, results,
sessions, entries);
var anna = vm.Rows.Single(r => r.StudentId == Anna.Id);
Assert.Equal(50.0, anna.AbsenceRatePercent);
Assert.True(anna.HasHighAbsenceRate);
var ben = vm.Rows.Single(r => r.StudentId == Ben.Id);
Assert.Equal(0.0, ben.AbsenceRatePercent);
Assert.False(ben.HasHighAbsenceRate);
}
[Fact]
public void ResolveScheme_BevorzugtGruppenspezifischesSchemaVorVoreinstellung()
{
@@ -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,
};
}
@@ -1,11 +1,41 @@
using LehrerApp.Core.Models;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SeatingPlanViewModelTests
{
[Fact]
public void TafelpositionWirdAusPlanGeladenUndBeimBearbeitenGespeichert()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan
{
GroupId = groupId,
Name = "Standard",
Rows = 1,
Columns = 1,
IsBoardAtBottom = true,
};
var plans = new FakeSeatingPlans([plan]);
var tabVm = new SeatingPlanTabViewModel(
plans, new FakeStudents([]), new FakeMemberships([]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
tabVm.Initialize(groupId, isReadOnly: false);
Assert.True(tabVm.IsBoardAtBottom);
Assert.False(tabVm.IsBoardAtTop);
var dialogVm = tabVm.CreateDialogViewModel(plan);
dialogVm.IsBoardAtBottom = false;
dialogVm.SaveCommand.Execute(null);
Assert.False(plans.GetById(plan.Id)!.IsBoardAtBottom);
}
[Fact]
public void AuswahlEinesBereitsZugeordnetenSchuelers_VerschiebtIhnAufDenNeuenPlatz()
{
@@ -27,6 +57,7 @@ public sealed class SeatingPlanViewModelTests
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.Seats[1].SelectedOption = vm.StudentOptions.Single(o => o.StudentId == student.Id);
@@ -75,6 +106,7 @@ public sealed class SeatingPlanViewModelTests
var vm = new SeatingPlanTabViewModel(plans, students, memberships,
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.IsEditMode = true;
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
@@ -83,6 +115,350 @@ 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()
{
var groupId = Guid.NewGuid();
var student = new Student { FirstName = "Anna", LastName = "A" };
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 1, Columns = 2,
Assignments = [new SeatAssignment { Row = 0, Column = 0, StudentId = student.Id }],
};
var vm = new SeatingPlanTabViewModel(
new FakeSeatingPlans([plan]), new FakeStudents([student]),
new FakeMemberships([new GroupMembership { GroupId = groupId, StudentId = student.Id }]),
new FakeSessions([]), new FakeEntries(), new FakeAspects());
vm.Initialize(groupId, isReadOnly: false);
vm.MoveSeat(vm.Seats[0], vm.Seats[1]);
Assert.False(vm.IsEditMode);
Assert.False(vm.CanEditLayout);
Assert.False(vm.EditPlanCommand.CanExecute(null));
Assert.Equal(student.Id, vm.Seats[0].SelectedOption.StudentId);
Assert.Null(vm.Seats[1].SelectedOption.StudentId);
vm.IsEditMode = true;
Assert.True(vm.CanEditLayout);
Assert.True(vm.EditPlanCommand.CanExecute(null));
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()
{
var groupId = Guid.NewGuid();
var plan = new SeatingPlan
{
GroupId = groupId, Name = "Standard", Rows = 2, Columns = 4,
ColumnGapWidths = [0, 80, 20],
};
var plans = new FakeSeatingPlans([plan]);
var vm = new SeatingPlanDialogViewModel(plans, groupId, plan);
Assert.Equal([0m, 80m, 20m], vm.ColumnGaps.Select(g => g.Width));
vm.ColumnGaps[0].Width = 40;
vm.SaveCommand.Execute(null);
Assert.Equal([40d, 80d, 20d], plans.GetById(plan.Id)!.ColumnGapWidths);
}
[Fact]
public void SitzplanPanel_FuegtAbstandNurAnKonfigurierterSpaltengrenzeEin()
{
var panel = new SeatingPlanPanel
{
Columns = 3,
ColumnGapWidths = [0, 70],
};
for (var i = 0; i < 6; i++)
panel.Children.Add(new Avalonia.Controls.Border { Width = 100, Height = 50 });
panel.Measure(Avalonia.Size.Infinity);
panel.Arrange(new Avalonia.Rect(panel.DesiredSize));
Assert.Equal(370, panel.DesiredSize.Width);
Assert.Equal(100, panel.DesiredSize.Height);
Assert.Equal(0, panel.Children[0].Bounds.X);
Assert.Equal(100, panel.Children[1].Bounds.X);
Assert.Equal(270, panel.Children[2].Bounds.X);
Assert.Equal(270, panel.Children[5].Bounds.X);
}
[Fact]
public void SitzplatzBewertung_ErstelltHeutigeSitzungUndSpeichertAlleDreiBereiche()
{
@@ -96,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);
@@ -105,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]
@@ -120,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());
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,12 +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(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.SelectedStateName = "Bayern";
@@ -166,12 +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(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(),
TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel());
vm.PeriodTimes[0].StartText = "08:00";
vm.PeriodTimes[0].EndText = "08:45";
@@ -193,12 +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(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue());
new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(),
TestSupport.BuildWebUntisSettingsService(),
TestSupport.BuildAnnualPlanSettingsService(),
TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(),
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));
}
}
@@ -59,6 +59,28 @@ public sealed class SyncSettingsServiceTests
Assert.DoesNotContain("geheimes-token-123", raw);
}
[Fact]
public void SetCredentialsAndToken_MitUserId_PersistiertLastUserIdUeberNeueInstanz()
{
var path = BuildTempPath();
new SyncSettingsService(path).SetCredentialsAndToken("Sebastian", "token-abc", "sebastian");
var reloaded = new SyncSettingsService(path);
Assert.Equal("sebastian", reloaded.LastUserId);
}
[Fact]
public void SetCredentialsAndToken_OhneUserId_LaesstVorhandeneLastUserIdUnveraendert()
{
var service = new SyncSettingsService(BuildTempPath());
service.SetCredentialsAndToken("sebastian", "token-1", "sebastian");
service.SetCredentialsAndToken("sebastian", "token-2");
Assert.Equal("sebastian", service.LastUserId);
}
[Fact]
public void Logout_EntferntTokenBehaeltAberServerUrlUndUsername()
{
@@ -1,7 +1,10 @@
using System.Net;
using System.Net.Http.Json;
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -33,16 +36,106 @@ public sealed class SyncStatusViewModelTests
Assert.Equal("Kein Server konfiguriert", vm.StatusText);
}
/// Regression (TODO 10.1.11): eine per Sync-Pull eingehende Änderung erschien im UI erst nach
/// manuellem Neuladen (z.B. Tab-Wechsel), weil EventApplier absichtlich an jedem ViewModel
/// vorbei direkt auf die LiteDB schreibt. SyncStatusViewModel.DataChanged ist der Hook, über
/// den MainWindowViewModel die sichtbare Seite danach neu lädt - hier wird nur geprüft, dass
/// das Ereignis von SyncEngine bis hierher tatsächlich durchgereicht wird.
[Fact]
public async Task DataChanged_PullMitEreignissen_WirdVonEngineDurchgereicht()
{
using var temp = new TempSyncEngine(new PullEventStubHandler());
var vm = new SyncStatusViewModel(temp.Engine);
var fired = false;
vm.DataChanged += () => fired = true;
await temp.Engine.SyncNowAsync();
Assert.True(fired);
}
[Fact]
public async Task DataChanged_PullOhneEreignisse_FeuertNicht()
{
using var temp = new TempSyncEngine(new EmptyPullStubHandler());
var vm = new SyncStatusViewModel(temp.Engine);
var fired = false;
vm.DataChanged += () => fired = true;
await temp.Engine.SyncNowAsync();
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(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri!.AbsolutePath == "/api/sync/pull")
{
var evt = new SyncEvent
{
DeviceId = "other-device", DeviceType = DeviceType.Desktop,
EntityType = "Student", EntityId = Guid.NewGuid().ToString(),
Operation = "Save", Payload = "irrelevant-fuer-diesen-test", SequenceNr = 1,
};
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{ Content = JsonContent.Create(new PullResponse { Events = [evt], ServerSequenceNr = 1 }) });
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{ Content = JsonContent.Create(new PushResponse()) });
}
}
private sealed class EmptyPullStubHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(request.RequestUri!.AbsolutePath == "/api/sync/pull"
? new PullResponse()
: (object)new PushResponse()),
});
}
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(
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
private readonly LiteDbContext _db = new(new MemoryStream());
private readonly HttpClient _http = new();
private readonly HttpClient _http;
public SyncEngine Engine { get; }
public TempSyncEngine()
public TempSyncEngine(HttpMessageHandler? handler = null)
{
_http = handler is null ? new HttpClient() : new HttpClient(handler);
_http.BaseAddress = new Uri("https://example.invalid");
var queue = new EventQueue(_queuePath);
var key = SyncCrypto.GenerateKey();
Engine = new SyncEngine(
@@ -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 —
@@ -132,6 +137,88 @@ public sealed class TimetableViewModelTests
Assert.True(item.HasExam);
}
[Fact]
public void Load_HeutigeStunde_VorherigeUnkontrollierteHausaufgabe_ZeigtBadge()
{
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 lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3" });
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var item = Assert.Single(vm.TodayItems);
Assert.True(item.HasUnhandledHomework);
}
[Fact]
public void Load_HeutigeStunde_HausaufgabeKontrolliert_KeinBadge()
{
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 lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3", HomeworkChecked = true,
});
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var item = Assert.Single(vm.TodayItems);
Assert.False(item.HasUnhandledHomework);
}
[Fact]
public void Load_HeutigeStunde_HausaufgabenKontrolleBewusstAbgeschaltet_KeinBadge()
{
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 lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = group.Id, Date = today.AddDays(-3), Homework = "S. 42, Aufgabe 3",
HomeworkCheckDismissed = true,
});
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var item = Assert.Single(vm.TodayItems);
Assert.False(item.HasUnhandledHomework);
}
[Fact]
public void Load_HeutigeStunde_VorherigeStundeOhneHausaufgabe_KeinBadge()
{
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 lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = today.AddDays(-3) });
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var item = Assert.Single(vm.TodayItems);
Assert.False(item.HasUnhandledHomework);
}
[Fact]
public void Load_Wochenkachel_VorherigeUnkontrollierteHausaufgabe_ZeigtBadge()
{
var group = new LearningGroup { Name = "Q1 Chemie" };
var date = DateInCurrentWeek(DayOfWeek.Wednesday);
var slots = new FakeTimetableSlots();
slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Wednesday, PeriodNumber = 1 });
var lessons = new FakeLessons();
lessons.Add(new Lesson { GroupId = group.Id, Date = date.AddDays(-2), Homework = "Vokabeln lernen" });
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Wednesday && c.PeriodNumber == 1);
Assert.True(cell.HasUnhandledHomework);
}
[Fact]
public void OpenGroup_RuftOnNavigateToGroupMitDerGroupIdAuf()
{
@@ -148,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()
{
@@ -262,6 +550,27 @@ public sealed class TimetableViewModelTests
Assert.False(cell.HasExperiment);
}
[Fact]
public void Load_Wochenkachel_UnvollstaendigePhasendaten_VerursachenKeinenFehler()
{
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 lessons = new FakeLessons();
lessons.Add(new Lesson
{
GroupId = group.Id,
Date = date,
Phases = [new LessonPhaseStep { Name = null!, Activity = null!, Material = "Experimentiermaterial" }],
});
var vm = BuildViewModel(slots, new FakeGroups([group]), lessons: lessons);
var cell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Monday && c.PeriodNumber == 1);
Assert.True(cell.HasExperiment);
}
[Fact]
public void Load_Wochenkachel_LetzteStundeVorKlausur_ZeigtIcon()
{
@@ -403,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()
{
@@ -521,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()
{
@@ -724,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()
{
@@ -835,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);
}
}

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