merge: Phase 10a learning tasks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:31:20 +02:00
commit e1c1492fc5
70 files changed
+13390 -13

No files matched your search

+6 -1
View File
@@ -87,6 +87,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020 |
| Chat ✅ | `ChatChannel` (Family / Direct), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage), `ChatAttachment`, `ChatReaction` |
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel für 10b, RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason); `PointsLedgerEntry.LearningSetId` – ADR-0021 |
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
## 4. Rollen- und Berechtigungsmodell
@@ -114,6 +115,8 @@ oder **None (–)**. Die Api registriert je Permission eine Policy `Permission:<
| `FamilyChat` | F | F | F | – ² | |
| `DirectMessages` | F | F | R | – | nur an Familienmitglieder |
| `AiUse` | F | F | – | – | KI-Funktionen nutzen (ADR-0020) |
| `LearningManage` | F | F | – | – | Übungsblätter anlegen/zuweisen/prüfen/bestätigen (ADR-0021) |
| `LearningSolve` | F | F | R | – | Kind: nur eigene, zugewiesene Blätter lösen; fremde → 404 (ADR-0021) |
| `UserManagement`, `CalendarManagement`, `SettingsManagement`, `AuditLogView` | F | – | – | – | |
¹ `F`, wenn „Gäste dürfen die Einkaufsliste ergänzen/abhaken“ aktiviert ist. ² `F`, wenn „Gäste dürfen den Familienchat nutzen“ aktiviert ist.
@@ -153,8 +156,9 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
| ICS-Feed | `GET /ics/{token}.ics` (öffentlich, Token-gesichert, ETag/Last-Modified) |
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019) |
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`; Eltern: `GET /api/ai/status` (ADR-0020) |
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit` (ADR-0021) |
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018) |
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`; das Dashboard lädt bei diesen Ereignissen neu) |
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
| Betrieb | `/health`, `/alive` |
## 7. Frontend
@@ -173,6 +177,7 @@ SignalR-Hub `/hubs/live` in der Api, vom Web-Host per YARP (WebSockets) durchger
`family` (ohne Gäste), `user:{id}`. Der Chat nutzt keine Kanal-Gruppen, sondern sendet an die `user:{id}` der jeweils aktuell Berechtigten (ADR-0019). Slices senden nach dem Speichern über `IHubContext<LiveHub>`
Ereignisse mit dem vollständigen DTO (z. B. `ShoppingItemChanged`), die Clients aktualisieren gezielt ihre Ansicht.
Aufgaben gehen wegen privater Aufgaben nur an `user:{id}` der Benutzer, die sie sehen dürfen (`TaskChanged`/`TaskRemoved`, ADR-0014).
Lern-Ereignisse (`LearningChanged(setId, kind)`) enthalten **keine Inhalte** und gehen nur an die Zielperson und alle Eltern (`user:{id}`, `LearningNotifier`, ADR-0021); die Clients laden über die geschützten Endpunkte nach.
Der Client (`LiveConnection`) verbindet sich mit unbegrenzten Wiederholungen neu und lädt danach den aktuellen Stand.
## 8a. Offline
+10 -4
View File
@@ -118,10 +118,16 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] Tests: Unit (Adressen/Heimnetz/Schlüsselpflicht/Limits/Zwecke/Validator, Adapter gegen Fake-Handler inkl. Fehlerübersetzung und JSON-Extraktion), bUnit (Verwaltung, Dialog, Test, Nutzung, Löschen), Integration (Rechte, Validierung, Schlüsselschutz, Zweck-Übernahme, Verbindungstest gegen Fake-Anbieter inkl. Timeout, OpenAI-Format, Tageslimit + Protokoll)
## Phase 10 – Lernaufgaben (ADR-0021)
- [ ] 10a: Übungsblätter manuell anlegen/zuweisen, Kind löst digital (Zahl/Auswahl/Text), automatische Bewertung ohne KI, Elternprüfung, Kinderansicht, Dashboard-Kachel, optional Punkte über das Punktebuch
- [ ] 10b: KI-Erzeugung per Prompt (z. B. „20 Aufgaben Mathe, 3. Klasse, leicht bis schwer“) als Entwurf, Eltern prüfen/ändern vor dem Zuweisen, Mathe-Lösungen serverseitig nachrechnen
- [ ] 10c: Papierlösung per Foto (Upload je Aufgabe/Blatt), Prüfung manuell durch Eltern, danach KI-Prüfung mit Sicherheitsangabe und Eltern-Bestätigung
- [ ] Rechte `LearningManage`/`LearningSolve`: Kinder sehen nur eigene Blätter, nie Musterlösungen oder Prompts (serverseitig); Foto-Aufbewahrungsfrist
- [x] 10a: Übungsblätter manuell anlegen/zuweisen (Entwurf → Zugewiesen → In Arbeit → Abgegeben → Geprüft), Editor für Blatt und Aufgaben (Zahl/Text/Auswahl, Hinweis, Erklärung, Schwierigkeit, Punkte), Versionsprüfung (409), Bearbeitungssperre nach dem ersten Versuch; Migration `Phase10aLearning`
- [x] 10a: Kind löst digital (eigene Blätter, Versuch starten/fortsetzen, Antworten zustandsbasiert speichern, Fortschritt, Abgabe-Bestätigung, Ergebnis mit Lösungen nur nach Regel), automatische Bewertung ohne KI (Zahlenformate mit Komma/Punkt/Tausender, Toleranz, Auswahl, Freitext exakt oder offen), Elternprüfung (Warteschlange, Korrektur, Teilpunkte, Begründung, Bestätigen, erneut öffnen)
- [x] 10a: Wiederholen (`MaxAttempts`, 0 = unbegrenzt), Zeitlimit serverseitig (Frist in der Datenbank, 10 s Toleranz, 409 bei späten Speicherungen, automatische Abgabe lazy und per Hintergrunddienst), Prüfungsmodus (keine Sofort-Rückmeldung/Hinweise/Lösungen bis zur Abgabe, optional gemischt und stabil je Versuch, Countdown nach Serveruhr, Auto-Abgabe), Note nach Notenschlüssel
- [x] 10a: Punkte über das Punktebuch (anteilig, beste Wertung zählt einmal, Differenz wird gebucht, Rückbuchung beim erneuten Öffnen, Audit, Live `PointsChanged`), Dashboard-Kachel „Lernen“ (Kind: offene Blätter, Eltern: zu prüfen), Live `LearningChanged` nur an Zielperson und Eltern
- [x] 10a: Rechte `LearningManage`/`LearningSolve` in der Matrix (Kinder nur eigene Blätter, fremde → 404, nie Musterlösung/Hinweis/Prompt vorab; serverseitig erzwungen), Navigation „Lernen“, Seiten und Komponenten (mobil, Touch-Ziele, ARIA)
- [x] 10a: Tests: Unit (Zahlenformate, Auto-Bewertung, Zeitlimit, Wiederholen, Punkte-Differenz, Noten, Prüfungsregeln, Kinder-DTO-Sichtbarkeit, Matrix, Validatoren), bUnit (Kinderansicht, Prüfungs-Runner mit steuerbarer Uhr, Elternansicht, Editor, Prüfung, Rechte, Dashboard-Kachel), Integration (Rechte je Rolle inkl. 404, Lösungen-Leck, Auto-Bewertung, Zeitlimit-409 mit echtem Warten, Wiederholungen, Punkte-Workflow inkl. Rückbuchung, Live nur an Berechtigte)
- [ ] 10a offen: „Aufgaben aus früheren Blättern wiederverwenden“, Verknüpfung eines Blatts mit einer Familienaufgabe/einem Ämtchen, Standard-Wiederholungen als Familieneinstellung (aktuell Voreinstellung im Editor)
- [ ] 10b: KI-Erzeugung per Prompt (z. B. „20 Aufgaben Mathe, 3. Klasse, leicht bis schwer“) als Entwurf, Eltern prüfen/ändern vor dem Zuweisen, Mathe-Lösungen serverseitig nachrechnen (Spalten für Herkunft/Prompt/Anbieter/Modell am Blatt sind vorhanden)
- [ ] 10c: Papierlösung per Foto (Upload je Aufgabe/Blatt), Prüfung manuell durch Eltern, danach KI-Prüfung mit Sicherheitsangabe und Eltern-Bestätigung (`LearningAnswerType.Paper`, `LearningGrader.Ai` sind als Werte vorgesehen)
- [ ] Foto-Aufbewahrungsfrist (Standard 30 Tage nach Prüfung, einstellbar) – mit 10c
## Phase 11 – Härtung
- [ ] Content-Security-Policy (Basis-Header X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS sind seit Phase 1 aktiv)
+4 -1
View File
@@ -4,7 +4,7 @@
* Datum: 2026-10-10
## Kontext
Die Startseite zeigt Kacheln aus fünf Fachbereichen (Essenplan, Kalender, Einkauf, Aufgaben, Chat). Rollen sehen unterschiedliche
Die Startseite zeigt Kacheln aus sechs Fachbereichen (Essenplan, Kalender, Einkauf, Aufgaben, Chat, Lernen). Rollen sehen unterschiedliche
Kacheln („Gast: eingeschränkte Kacheln“, Familien-/Kinderaufgaben nur für Eltern). Auf dem Smartphone zählen wenige Requests.
## Entscheidung
@@ -25,6 +25,9 @@ Kacheln („Gast: eingeschränkte Kacheln“, Familien-/Kinderaufgaben nur für
6. **Offline**: Bei Fehlern bleibt der zuletzt geladene Stand mit Hinweis sichtbar; ohne Stand gibt es „Erneut versuchen“.
7. **Chat** (Phase 8, ADR-0019): Die Kachel erscheint für Rollen mit Chat-Rechten und zeigt die Summe der ungelesenen Nachrichten über alle sichtbaren Kanäle
(`ChatAccessService.TotalUnreadAsync`); die Seite lädt bei Chat-Ereignissen (neue Nachricht, gelöscht, gelesen) neu.
8. **Lernen** (Phase 10a, ADR-0021): `DashboardDto.Learning` (letzter Konstruktorparameter, standardmäßig `null`). Eltern (`LearningManage`) sehen die Zahl der abgegebenen, noch nicht bestätigten Versuche und die offenen Blätter der Kinder, die ältesten
Abgaben (höchstens fünf) verlinken direkt auf die Prüfung; Kinder sehen ihre offenen Blätter (zugewiesen oder in Arbeit) mit Fälligkeit, ein laufender Versuch verlinkt auf „Weitermachen“. Gäste und Rollen ohne Recht erhalten keine Kachel. Die Seite lädt
bei `LearningChanged` neu (ohne Inhalte, nur an Zielperson und Eltern).
## Folgen
* Ein Request je Seitenaufruf; Kosten wachsen mit der Familiengröße, nicht mit der Datenmenge (Aufgaben: nicht erledigte, Termine: zwei Tage).
+74 -1
View File
@@ -1,6 +1,6 @@
# ADR-0021: Lernaufgaben – manuell oder per KI, digital oder auf Papier
* Status: akzeptiert (Phase 10; Entscheidungen der Rückfragen am 2026-10-10 eingearbeitet)
* Status: akzeptiert (Phase 10; Entscheidungen der Rückfragen am 2026-10-10 eingearbeitet) – **Phase 10a umgesetzt** (Abschnitt „Umsetzung Phase 10a“), 10b und 10c offen
* Datum: 2026-10-10
## Kontext
@@ -60,6 +60,79 @@ Lernaufgaben sind **kein** Teil der Familienaufgaben (ADR-0014): eigene Entität
Papier-Prüfungen: Foto-Abgabe innerhalb des Zeitfensters.
Im Übungsmodus (`Practice`) gibt es Sofort-Rückmeldung und Hinweise.
## Umsetzung Phase 10a (2026-10-10)
Phase 10a liefert manuell erstellte Übungsblätter, digitales Lösen, automatische Bewertung, Elternprüfung, Punkte und den Prüfungsmodus – **ohne KI (10b) und ohne Foto/Papier (10c)**.
Migration `Phase10aLearning`. Reine Regeln liegen in `FamilyPlanner.Domain/Learning` (identisch in Api und Client), der Slice in `Api/Features/Learning`, DTOs und Validatoren in `Shared/Learning`,
die Oberfläche in `Web.Client/Components/Learning` und `Pages/Learning*.razor`.
### Modell
* `LearningSet` (Soft Delete, `RowVersion`): Titel, Fach als Text aus `LearningSubjects` (Mathe, Deutsch), Klassenstufe 1–6, `TargetUserId` (Rolle Kind, bloße Id ohne Fremdschlüssel wie bei Aufgaben), `DueDate`,
`Status` (Entwurf → Zugewiesen → In Arbeit → Abgegeben → Geprüft), `Mode` (`Practice`/`Exam`), `MaxAttempts` (0 = unbegrenzt), `TimeLimitMinutes` (`null` = keins), `ShowSolutionsAfterSubmit`, `ShuffleItems`,
`TotalPoints` (0 = keine Punkte), `GradeThresholds` (Notenschlüssel, `null` = Standard), `Source` (`Manual`|`Ai`) sowie `AiPrompt`/`AiProvider`/`AiModel` (**für 10b schon angelegt**, vom Client nicht setzbar),
`AssignedAt`, `ItemsRevision` (Zähler, damit Aufgabenänderungen und ein gleichzeitiger Versuchsstart über die `RowVersion` des Blatts kollidieren).
* `LearningItem` (Soft Delete, `RowVersion`): `SortOrder`, `Question`, `AnswerType` (`Number`, `Text`, `SingleChoice`; `Paper` nur als Enum-Wert, der Validator lehnt ihn in 10a ab), `CorrectAnswer`
(Zahl, Text – Varianten mit `|` – oder Index der richtigen Option), `NumberTolerance` (absolut), `Options` (2–6, JSON-Liste), `Hint` und `Explanation` (zwei Felder statt „Hinweis/Erklärung“), `Difficulty` 1–3, `Points` 1–20.
* `LearningAttempt` (`RowVersion`): `Number`, `Status` (Läuft/Abgegeben/Bestätigt), `StartedAt`, `DeadlineAt`, `LastActivityAt`, `SubmittedAt`, `AutoSubmitted`, `ItemOrder` (Aufgaben-Ids dieses Versuchs, ggf. gemischt),
`ReviewedAt/By`, `EarnedPoints`, `TotalItemPoints`, `Grade`, `ReviewNote`. `LearningAnswer`: `Value`, `Verdict` (`Open`/`Correct`/`Partial`/`Wrong`), `Grader` (`Auto`/`Ai`/`Parent`), `AwardedPoints`, `Reason`.
Eindeutige Indizes: (Blatt, Kind, Nummer) und **höchstens ein laufender Versuch** je Blatt und Kind (gefiltert auf `InProgress`) – Doppelklick oder zwei Tabs können weder `MaxAttempts` umgehen noch die Prüfungsuhr zurücksetzen.
`LearningAnswer.ItemId` ist bewusst keine Beziehung (SQL Server erlaubt keine zweiten Kaskadenpfade; Aufgaben ändern sich nach dem ersten Versuch nicht mehr).
* `PointsLedgerEntry.LearningSetId` (neu, nullable): Buchungen zu einem Blatt; der Punktestand bleibt die Summe des Punktebuchs.
### Regeln (alle unit-getestet)
* **Auto-Bewertung** (`LearningGrading`): Zahlen werden kulturunabhängig gelesen (Komma/Punkt als Dezimalzeichen, Punkt/Komma/Leerzeichen als Tausendertrenner, Unicode-Minus); „1.234“ ist mehrdeutig und passt, wenn
**eine** Lesart zur Musterlösung passt; Toleranz absolut; Brüche („1/2“) werden **nicht** erkannt (→ falsch, Eltern können korrigieren). Auswahl vergleicht den Index. Freitext ist nur bei **exakter Übereinstimmung nach Normalisierung**
(Unicode, Leerraum, Groß/Klein, Randzeichen, Varianten mit `|`) automatisch richtig, sonst `Open` für die Elternprüfung – nie automatisch falsch. Leere Antworten sind immer falsch.
* **Zeitlimit serverseitig** (`LearningRules`): `DeadlineAt` = Start + Limit wird beim Start in der Datenbank festgelegt. Speicherungen gelten bis **Frist + 10 s Toleranz**; danach antwortet die Api mit **409**, der Versuch wird automatisch
abgegeben (`SubmittedAt` = Frist, `AutoSubmitted`), nur zuvor gespeicherte Antworten zählen. Die Abgabe selbst ist idempotent und nie ein 409. Automatische Abgabe passiert **lazy** bei jedem Zugriff (Kind: Liste/Versuch/Speichern/Starten,
Eltern: Liste/Warteschlange/Versuch) und per Hintergrunddienst `LearningExpiryService` (alle 30 s), damit verlassene Prüfungen in der Warteschlange landen. Der Client rechnet den Countdown aus `DeadlineAt` und dem Uhrenabgleich (`ServerNow`),
nicht aus der Geräteuhr.
* **Sichtbarkeit** (`LearningRules.VisibilityFor`, einzige Quelle für Kinder-DTOs): Übung – Hinweise und Sofort-Rückmeldung (nur richtig/falsch/offen, **nie** die Lösung) während der Bearbeitung; Prüfung – während der Bearbeitung **nichts**
(Speicherantwort ohne Bewertung, kein Hinweis); nach der Abgabe immer die Bewertung, die Lösung samt Erklärung nur bei „Lösungen nach Abgabe zeigen“, Hinweise nur in der Übung. Kinder-DTOs sind eigene Typen ohne Felder für Prompt, Anbieter
oder Modell; `CorrectAnswer`/`Explanation` stehen nur im optionalen `Feedback`. Kommentare der Eltern erreichen das Kind erst nach der Abgabe, Meldungen der Automatik nie als „Kommentar“.
* **Reihenfolge:** gemischt wird deterministisch aus SHA-256 von Versuchs- und Aufgaben-Id (`string.GetHashCode` wäre je Prozess zufällig) und in `ItemOrder` gespeichert – stabil je Versuch, zwischen Versuchen wechselnd.
* **Status** wird aus den Versuchen abgeleitet (`DeriveStatus`): läuft einer → In Arbeit, sonst wartet einer → Abgegeben, sonst bestätigt → Geprüft, sonst Zugewiesen.
* **Elternprüfung:** Antworten bewerten (`Correct`/`Partial`/`Wrong`, Teilpunkte 1 bis Aufgabenpunkte − 1, Begründung), auch die der Automatik korrigieren; **Bestätigen** erst ohne offene Antworten (409). Auch vollautomatisch bewertete Blätter
werden bestätigt – erst dann gibt es Punkte und Note. „Erneut öffnen“ macht den Versuch wieder prüfbar.
* **Punkte:** Anteil = erreichte Aufgabenpunkte / mögliche; Gutschrift = **beste bestätigte Wertung** × `TotalPoints`, kaufmännisch auf ganze Punkte gerundet (halbe aufwärts, reine Ganzzahlrechnung). Gebucht wird nur die **Differenz zum bisher für das
Blatt gebuchten Stand** (`SyncPointsAsync`): Gutschrift, Verbesserung oder Rückbuchung – dadurch idempotent (zweimal bestätigen bucht nichts), und Erneut-Öffnen fällt auf die nächstbeste Wertung zurück. Audit-Einträge
`learning.points_credited`/`learning.points_reversed`, Live-Ereignis `TaskPointsChanged` an die Familie. Ist `ChorePointsEnabled` aus, wird nichts gebucht. Das Löschen eines Blatts lässt bereits gutgeschriebene Punkte bestehen (wie bei Ämtchen,
ADR-0014); Korrekturen über die Punkteverwaltung.
* **Note** (nur Prüfungen, nach Bestätigung): Schwellen für die Noten 1–5 in Prozent, Standard `92;81;67;50;30`, darunter 6 (ganzzahliger Vergleich ohne Rundungsfehler); einstellbar je Blatt, streng absteigend.
* **Bearbeitungssperre:** Aufgaben lassen sich nur ändern, solange es keinen Versuch gibt; danach sind auch Kind, Modus, Mischen, Gesamtpunkte und Notenschlüssel fest (409). Titel, Fälligkeit, Zeitlimit, Versuche und „Lösungen zeigen“ bleiben änderbar.
### Rechte
`LearningManage` (Admin F, Familie F, Kind –, Gast –) und `LearningSolve` (Admin F, Familie F, Kind R, Gast –). Blätter lassen sich nur **Kindern** zuweisen; gelöst wird ausschließlich von der Zielperson (`LearningRules.CanSolve`) – auch Eltern lösen
nicht für andere: fremde Blätter und Versuche antworten mit **404**, Entwürfe sind für das Zielkind unsichtbar. Kinder auf Verwaltungsendpunkten und Gäste überall erhalten 403 (Policy). Objektbezogene Prüfungen im Slice, nie nur im UI.
### Schnittstellen und Oberfläche
* Eltern (`/api/learning`): `GET/POST /sets` (POST optional mit `Items`), `GET/PUT/DELETE /sets/{id}`, `POST …/assign`, `POST …/unassign`, `POST …/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /review` (Warteschlange),
`GET /attempts/{id}`, `PUT /attempts/{id}/answers/{itemId}/review`, `POST …/confirm`, `POST …/reopen`. Versionsprüfung mit 409 und aktuellem Stand bei Blatt und Aufgabe.
* Kinder (`/api/learning/my`): `GET /`, `POST /{setId}/attempts` (startet oder setzt fort), `GET /attempts/{id}`, `PUT /attempts/{id}/answers/{itemId}` (zustandsbasiert, idempotent, 409 bei abgegeben/abgelaufen), `POST /attempts/{id}/submit`.
* Live: `LearningChanged(setId, kind)` **ohne Inhalte** an `user:{id}` der Zielperson und aller Eltern (`LearningNotifier`); Clients laden über die geschützten Endpunkte nach. Dashboard: Kachel „Lernen“ (`DashboardDto.Learning`).
* UI: Navigation „Lernen“ (Admin/Familie/Kind), Routen `/lernen`, `/lernen/neu`, `/lernen/blatt/{id}`, `/lernen/versuch/{id}`, `/lernen/pruefen/{id}`. Kind: Karten, **Prüfungs-Runner** (`AttemptRunner`) mit Countdown, Fortschritt,
Aufgaben-Navigation, serialisiertem Speichern, Abgabe-Bestätigung und Auto-Abgabe (Wiederholung bei Netzfehlern); Eltern: Übersicht, Editor (`SetEditor`, `ItemEditDialog`), Prüfung (`AttemptReview`).
### Abweichungen und bewusste Vereinfachungen
* **Standard-Wiederholungen** sind eine Voreinstellung im Editor (Übung: unbegrenzt, Prüfung: 1 Versuch ohne Lösungen), keine Familieneinstellung – das hätte DTO, Validator, Verwaltung und Tests berührt.
* Kein eigenes Feld „Schwierigkeitsverlauf“ am Blatt (ergibt sich aus den Schwierigkeiten der Aufgaben; relevant für den Prompt in 10b). Keine Verknüpfung mit einer Familienaufgabe/einem Ämtchen: Punkte laufen direkt über das Punktebuch. „Aufgaben aus früheren Blättern
wiederverwenden“ ist nicht umgesetzt.
* Die Elternbestätigung ist immer nötig (auch bei vollautomatischer Bewertung); Punkte und Note gibt es erst danach, das Kind sieht bis dahin ein vorläufiges Ergebnis.
* Sofort-Rückmeldung in der Übung bedeutet: Das Kind kann eine Antwort ändern, bis es abgibt; gewertet wird der letzte Stand. Die Übung ist ein Lernrahmen, keine Prüfung.
* Das Zeitlimit hat mindestens 1 Minute; die Toleranz (10 s) ist fest.
### Erweiterungspunkte für 10b (KI-Erzeugung)
* Die Spalten `Source`, `AiPrompt`, `AiProvider`, `AiModel` am `LearningSet` existieren; `POST /api/learning/sets` setzt immer `Manual`. 10b ergänzt einen eigenen Endpunkt in `Features/Learning` (z. B. `POST /api/learning/sets/ai-draft`, Policy `LearningManage` + `AiUse`),
der über `AiService.RunAsync(…, AiPurpose.LearningGeneration, …)` Aufgaben als strukturiertes JSON holt, jede Aufgabe mit `LearningItemInputValidator` und `LearningItemRules.CheckSolution` prüft, Zahlenlösungen mit `LearningNumbers`/`LearningGrading.Grade`
nachrechnet und das Ergebnis über denselben Pfad wie `LearningManageEndpoints.CreateSetAsync` als **Entwurf** mit `Source = Ai` speichert. Zuweisen bleibt der bestehende Schritt (`assign`).
* UI: ein Assistent-Dialog in `ParentOverview` (Knopf „Mit KI erstellen“, sichtbar anhand `GET /api/ai/status`), Ergebnis öffnet `SetEditor` im Entwurf; `ILearningApi` bekommt eine Methode dafür.
### Erweiterungspunkte für 10c (Papierlösung per Foto)
* `LearningAnswerType.Paper`, `LearningVerdict.Open` und `LearningGrader.Ai` sind vorhanden; `LearningGrading.Grade` liefert für `Paper` bereits `Open`, `LearningItemRules.IsSupported` und `CheckSolution` müssen `Paper` freischalten (Validator und Editor-Auswahl).
* Neue Entität `LearningPhoto` (Bytes in der Datenbank, an Versuch/Aufgabe gebunden, Signaturprüfung, Aufbewahrungsfrist) und Endpunkte unter `/api/learning/my/attempts/{id}/…` (Hochladen) bzw. `/api/learning/attempts/{id}/…` (Abruf nur für Eltern/Kind); `AttemptRunner`
bekommt für `Paper`-Aufgaben ein Upload-Feld statt Texteingabe, `AttemptReview` zeigt das Foto neben Aufgabe und Musterlösung. Die KI-Prüfung (`AiPurpose.LearningImageGrading`) schreibt `Verdict`, `Grader = Ai`, `Reason` und eine Sicherheitsangabe
(neue Spalte) in `LearningAnswer`; bestätigt wird weiterhin über `confirm`. Zeitfenster und Abgabe-Logik (`Submit`, `LearningExpiryService`) bleiben unverändert.
## Bewusst nicht Teil des ersten Schnitts
Offline-Lösen, Sprachausgabe/Diktat, Lernstatistiken über Wochen, Adaptive Schwierigkeit, Formeleditor (Aufgaben bleiben Text), Auswertung mehrerer Kinder im Vergleich,
Überwachung gegen Schummeln (die Emulation ist ein Übungsrahmen, keine Fernprüfung).
@@ -1,10 +1,12 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Identity;
@@ -57,7 +59,61 @@ public static class DashboardEndpoints
? new DashboardChatDto(await chatAccess.TotalUnreadAsync(user, ct))
: null;
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat));
var learning = await LoadLearningAsync(user, db, settingsProvider, today, ct);
return TypedResults.Ok(new DashboardDto(today, meals, events, shopping, myTasks, familyTasks, chat, learning));
}
private const int LearningTileItems = 5;
/// <summary>Lern-Kachel: Eltern sehen die Prüf-Warteschlange, Kinder ihre offenen Blätter; sonst keine Kachel.</summary>
private static async Task<DashboardLearningDto?> LoadLearningAsync(
ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, DateOnly today, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
if (actor.CanManage)
{
var open = await db.LearningSets.AsNoTracking()
.CountAsync(s => s.Status == LearningSetStatus.Assigned || s.Status == LearningSetStatus.InProgress, ct);
var queue = db.LearningAttempts.AsNoTracking()
.Where(a => a.Status == LearningAttemptStatus.Submitted)
.Join(db.LearningSets.AsNoTracking(), a => a.SetId, s => s.Id, (a, s) => new { Attempt = a, Set = s });
var toReview = await queue.CountAsync(ct);
var top = await queue.OrderBy(x => x.Attempt.SubmittedAt).Take(LearningTileItems)
.Select(x => new { AttemptId = x.Attempt.Id, x.Attempt.UserId, SetId = x.Set.Id, x.Set.Title, x.Set.Subject, x.Set.DueDate })
.ToListAsync(ct);
var names = await LearningAccess.DisplayNamesAsync(db, top.Select(t => t.UserId), ct);
return new DashboardLearningDto(
open,
toReview,
true,
top.Select(t => new DashboardLearningItemDto(t.SetId, t.AttemptId, t.Title, t.Subject, t.DueDate, false, names.GetValueOrDefault(t.UserId))).ToList());
}
if (actor.Solve == AccessLevel.None)
{
return null;
}
var me = actor.UserId;
var mine = await db.LearningSets.AsNoTracking()
.Where(s => s.TargetUserId == me && (s.Status == LearningSetStatus.Assigned || s.Status == LearningSetStatus.InProgress))
.Select(s => new
{
s.Id,
s.Title,
s.Subject,
s.DueDate,
RunningAttemptId = s.Attempts.Where(a => a.UserId == me && a.Status == LearningAttemptStatus.InProgress).Select(a => (Guid?)a.Id).FirstOrDefault(),
})
.ToListAsync(ct);
return new DashboardLearningDto(
mine.Count,
0,
false,
mine.OrderBy(s => s.DueDate ?? DateOnly.MaxValue).ThenBy(s => s.Title).Take(LearningTileItems)
.Select(s => new DashboardLearningItemDto(s.Id, s.RunningAttemptId, s.Title, s.Subject, s.DueDate, s.DueDate is { } due && due < today, null))
.ToList());
}
private static async Task<List<DashboardMealDto>> LoadMealsAsync(AppDbContext db, IEnumerable<string> configuredTypes, DateOnly today, CancellationToken ct)
@@ -0,0 +1,180 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Tasks;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Gemeinsame Abläufe der Versuche: Abgabe (auch automatisch nach Zeitablauf), Zeitablauf prüfen und Punkte im Punktebuch abgleichen.
/// Wird von den Endpunkten und vom Hintergrunddienst genutzt, damit die Regeln nur einmal existieren.
/// </summary>
public sealed class LearningAttemptService(
AppDbContext db, ISettingsProvider settingsProvider, LearningNotifier notifier, TimeProvider timeProvider, ILogger<LearningAttemptService> logger)
{
private const int MaxFinalizeBatch = 50;
public DateTimeOffset Now => timeProvider.GetUtcNow();
/// <summary>
/// Protokolliert einen abgelehnten Schreibzugriff (gleichzeitige Änderung oder Verstoß gegen einen eindeutigen Index). Solche Fälle sind erwartbar, sollen aber
/// sichtbar bleiben – eine falsch behandelte Entität sähe von außen genauso aus wie ein Konflikt.
/// </summary>
public void LogConflict(Exception exception, string operation) =>
logger.LogWarning(exception, "Lernaufgaben: {Operation} wurde wegen eines Konflikts abgelehnt.", operation);
/// <summary>Alle Antworten fertig bewerten (leere = falsch; Elternbewertungen bleiben) und den Versuch als abgegeben markieren.</summary>
public void Submit(LearningAttempt attempt, LearningSet set, DateTimeOffset now)
{
var items = set.Items.ToDictionary(i => i.Id);
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
var answer = attempt.Answers.SingleOrDefault(a => a.ItemId == id);
if (answer is null)
{
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = id };
db.LearningAnswers.Add(answer); // explizit: eine bereits gesetzte Id ließe EF die Entität sonst als vorhanden (Modified) behandeln
}
if (answer.Grader == LearningGrader.Parent)
{
continue;
}
var result = LearningGrading.Grade(item, answer.Value);
answer.Verdict = result.Verdict;
answer.AwardedPoints = result.AwardedPoints;
answer.Reason = result.Reason;
answer.Grader = LearningGrader.Auto;
answer.GradedAt = now;
}
attempt.Status = LearningAttemptStatus.Submitted;
attempt.SubmittedAt = LearningRules.SubmittedAtFor(attempt.DeadlineAt, now);
attempt.AutoSubmitted = attempt.DeadlineAt is { } deadline && now > deadline;
attempt.LastActivityAt = now;
RefreshStatus(set);
}
/// <summary>Status des Blatts aus den Versuchen neu bestimmen.</summary>
public static void RefreshStatus(LearningSet set) =>
set.Status = LearningRules.DeriveStatus(set.Status, set.Attempts.Select(a => a.Status).ToList());
/// <summary>
/// Gibt laufende Versuche ab, deren Frist samt Toleranz vorbei ist (ADR-0021 Punkt 12) – beim nächsten Zugriff und per Hintergrunddienst.
/// Mit <paramref name="userId"/> nur die Versuche dieses Kindes. Liefert die Zahl abgegebener Versuche.
/// </summary>
public async Task<int> FinalizeExpiredAsync(Guid? userId, CancellationToken ct)
{
var now = Now;
var cutoff = now - LearningRules.DeadlineTolerance;
var query = db.LearningAttempts
.Where(a => a.Status == LearningAttemptStatus.InProgress && a.DeadlineAt != null && a.DeadlineAt < cutoff)
.Where(a => db.LearningSets.Any(set => set.Id == a.SetId)); // gelöschte Blätter (Soft Delete) würden die Stapel dauerhaft blockieren
if (userId is { } id)
{
query = query.Where(a => a.UserId == id);
}
var ids = await query.OrderBy(a => a.DeadlineAt).Select(a => a.Id).Take(MaxFinalizeBatch).ToListAsync(ct);
var finalized = 0;
foreach (var attemptId in ids)
{
if (await TryFinalizeAsync(attemptId, ct))
{
finalized++;
}
}
return finalized;
}
/// <summary>Einen abgelaufenen Versuch abgeben; <c>false</c>, wenn er inzwischen anders abgegeben wurde oder noch läuft.</summary>
public async Task<bool> TryFinalizeAsync(Guid attemptId, CancellationToken ct)
{
for (var attempt = 1; attempt <= 3; attempt++)
{
var entity = await db.LearningAttempts.Include(a => a.Answers).SingleOrDefaultAsync(a => a.Id == attemptId, ct);
if (entity is null || entity.Status != LearningAttemptStatus.InProgress)
{
return false;
}
var now = Now;
if (!LearningRules.MustAutoSubmit(entity.DeadlineAt, now))
{
return false;
}
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery()
.SingleOrDefaultAsync(s => s.Id == entity.SetId, ct);
if (set is null)
{
return false; // Blatt gelöscht – der Versuch bleibt unverändert
}
var tracked = set.Attempts.Single(a => a.Id == entity.Id);
Submit(tracked, set, now);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
LogConflict(ex, "automatische Abgabe");
// Gleichzeitig vom Kind abgegeben oder neue Antwort gespeichert: Zustand neu laden und erneut bewerten.
db.ChangeTracker.Clear();
continue;
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Submitted, ct);
return true;
}
return false;
}
/// <summary>
/// Gleicht das Punktebuch mit den bestätigten Versuchen ab (ADR-0021 Punkt 10): Ziel ist die <b>beste</b> bestätigte Wertung, gebucht wird
/// nur die Differenz zum bisher gebuchten Stand (Gutschrift, Verbesserung oder Rückbuchung). Idempotent, da vom gebuchten Stand ausgegangen wird.
/// Ist das Punktesystem abgeschaltet, wird nichts gebucht. Liefert die Differenz und den neuen Stand; die Buchung ist vorgemerkt, nicht gespeichert.
/// </summary>
public async Task<(int Delta, int Credited)> SyncPointsAsync(LearningSet set, string reasonOnDecrease, CancellationToken ct)
{
var booked = await db.PointsLedger.Where(p => p.LearningSetId == set.Id).SumAsync(p => (int?)p.Points, ct) ?? 0;
var settings = await settingsProvider.GetAsync(ct);
if (!settings.ChorePointsEnabled)
{
return (0, booked);
}
var target = LearningRules.TargetCredit(
set.TotalPoints,
set.Attempts.Where(a => a.Status == LearningAttemptStatus.Reviewed && a.EarnedPoints is not null && a.TotalItemPoints is not null)
.Select(a => (a.EarnedPoints!.Value, a.TotalItemPoints!.Value)));
var delta = LearningRules.CreditDelta(target, booked);
if (delta == 0)
{
return (0, booked);
}
var reason = delta < 0
? reasonOnDecrease
: booked == 0 ? $"Lernblatt „{set.Title}“" : $"Lernblatt „{set.Title}“ verbessert";
db.PointsLedger.Add(new PointsLedgerEntry
{
UserId = set.TargetUserId,
Points = delta,
LearningSetId = set.Id,
Reason = reason.Length <= PointsLedgerEntry.ReasonMaxLength ? reason : reason[..(PointsLedgerEntry.ReasonMaxLength - 1)] + "…",
});
return (delta, target);
}
}
@@ -0,0 +1,13 @@
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>Lernaufgaben (ADR-0021): Eltern unter <c>/api/learning</c> (LearningManage), Kinder unter <c>/api/learning/my</c> (LearningSolve).</summary>
public static class LearningEndpoints
{
public static IEndpointRouteBuilder MapLearningEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/learning").WithTags("Lernen");
LearningManageEndpoints.Map(group);
LearningSolveEndpoints.Map(group);
return app;
}
}
@@ -0,0 +1,34 @@
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Gibt Versuche mit abgelaufenem Zeitlimit automatisch ab (ADR-0021 Punkt 12), auch wenn das Kind die App geschlossen hat –
/// damit landen sie in der Prüf-Warteschlange. Zusätzlich passiert dasselbe lazy bei jedem Zugriff (<see cref="LearningAttemptService"/>).
/// </summary>
internal sealed class LearningExpiryService(IServiceScopeFactory scopeFactory, TimeProvider timeProvider, ILogger<LearningExpiryService> logger)
: BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(Interval, timeProvider);
do
{
try
{
await SweepAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Abgelaufene Lernversuche konnten nicht abgegeben werden.");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
internal async Task<int> SweepAsync(CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<LearningAttemptService>().FinalizeExpiredAsync(null, ct);
}
}
@@ -0,0 +1,698 @@
using System.Security.Claims;
using FamilyPlanner.Api.Features.Tasks;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Audit;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>Elternseite der Lernaufgaben (<c>LearningManage</c>): Blätter und Aufgaben, Zuweisen, Prüf-Warteschlange, Bestätigen (ADR-0021).</summary>
internal static class LearningManageEndpoints
{
private const int MaxReviewQueue = 200;
public static void Map(RouteGroupBuilder group)
{
var manage = group.MapGroup(string.Empty).RequirePermission(Permission.LearningManage);
manage.MapGet("/sets", ListSetsAsync);
manage.MapPost("/sets", CreateSetAsync).Validate<CreateLearningSetRequest>();
manage.MapGet("/sets/{id:guid}", GetSetAsync);
manage.MapPut("/sets/{id:guid}", UpdateSetAsync).Validate<UpdateLearningSetRequest>();
manage.MapDelete("/sets/{id:guid}", DeleteSetAsync);
manage.MapPost("/sets/{id:guid}/assign", AssignAsync);
manage.MapPost("/sets/{id:guid}/unassign", UnassignAsync);
manage.MapPost("/sets/{id:guid}/items", AddItemAsync).Validate<LearningItemInput>();
manage.MapPut("/sets/{id:guid}/items/{itemId:guid}", UpdateItemAsync).Validate<UpdateLearningItemRequest>();
manage.MapDelete("/sets/{id:guid}/items/{itemId:guid}", DeleteItemAsync);
manage.MapPost("/sets/{id:guid}/items/order", ReorderItemsAsync).Validate<ReorderLearningItemsRequest>();
manage.MapGet("/review", ReviewQueueAsync);
manage.MapGet("/attempts/{attemptId:guid}", GetAttemptAsync);
manage.MapPut("/attempts/{attemptId:guid}/answers/{itemId:guid}/review", ReviewAnswerAsync).Validate<ReviewAnswerRequest>();
manage.MapPost("/attempts/{attemptId:guid}/confirm", ConfirmAsync).Validate<ConfirmAttemptRequest>();
manage.MapPost("/attempts/{attemptId:guid}/reopen", ReopenAsync);
}
// ---------- Blätter ----------
private static async Task<Ok<List<LearningSetDto>>> ListSetsAsync(AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var rows = await db.LearningSets.AsNoTracking()
.OrderByDescending(s => s.UpdatedAt)
.Select(s => new
{
Set = s,
ItemCount = s.Items.Count(),
AttemptCount = s.Attempts.Count(),
Pending = s.Attempts.Count(a => a.Status == LearningAttemptStatus.Submitted),
})
.ToListAsync(ct);
var credited = await CreditedAsync(db, rows.Select(r => r.Set.Id), ct);
return TypedResults.Ok(rows
.Select(r => LearningMapper.ToDto(r.Set, r.ItemCount, r.AttemptCount, r.Pending, credited.GetValueOrDefault(r.Set.Id)))
.ToList());
}
private static async Task<Results<Ok<LearningSetDetailDto>, NotFound>> GetSetAsync(Guid id, AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
return await LoadDetailAsync(db, id, ct) is { } detail ? TypedResults.Ok(detail) : TypedResults.NotFound();
}
private static async Task<Results<Created<LearningSetDetailDto>, ValidationProblem>> CreateSetAsync(
CreateLearningSetRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
if (await CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
var set = new LearningSet();
ApplySet(set, request);
var order = 0;
foreach (var input in request.Items ?? [])
{
var item = LearningValidation.ToItem(input);
item.SortOrder = order++;
set.Items.Add(item);
}
db.LearningSets.Add(set);
await db.SaveChangesAsync(ct);
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
var detail = (await LoadDetailAsync(db, set.Id, ct))!;
return TypedResults.Created($"/api/learning/sets/{set.Id}", detail);
}
private static async Task<IResult> UpdateSetAsync(
Guid id, UpdateLearningSetRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(set.RowVersion))
{
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
if (request.TargetUserId != set.TargetUserId && await CheckTargetAsync(db, request.TargetUserId, ct) is { } invalid)
{
return invalid;
}
if (set.Attempts.Count > 0 && LockedFieldChanged(set, request) is { } locked)
{
return EndpointHelpers.Conflict($"{locked} lässt sich nach dem ersten Versuch nicht mehr ändern.");
}
var previousTarget = set.TargetUserId;
ApplySet(set, request);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear();
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct, previousTarget != set.TargetUserId ? previousTarget : null);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
private static async Task<NoContent> DeleteSetAsync(Guid id, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NoContent(); // idempotent
}
// Bereits gutgeschriebene Punkte bleiben (wie beim Löschen erledigter Ämtchen, ADR-0014); Korrekturen über die Punkteverwaltung.
db.LearningSets.Remove(set);
await db.SaveChangesAsync(ct);
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Deleted, ct);
return TypedResults.NoContent();
}
private static async Task<IResult> AssignAsync(Guid id, AppDbContext db, LearningNotifier notifier, TimeProvider time, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (LearningRules.CheckAssignable(set.Status, set.Items) is { } error)
{
return EndpointHelpers.Conflict(error);
}
if (await CheckTargetAsync(db, set.TargetUserId, ct) is { } invalid)
{
return invalid;
}
set.Status = LearningSetStatus.Assigned;
set.AssignedAt = time.GetUtcNow();
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Das Blatt wurde inzwischen geändert. Bitte neu laden.");
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Assigned, ct);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
private static async Task<IResult> UnassignAsync(Guid id, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Attempts).SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return TypedResults.NotFound();
}
if (set.Status != LearningSetStatus.Assigned || set.Attempts.Count > 0)
{
return EndpointHelpers.Conflict("Nur Blätter ohne Versuch lassen sich wieder zum Entwurf machen.");
}
set.Status = LearningSetStatus.Draft;
set.AssignedAt = null;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return EndpointHelpers.Conflict("Das Blatt wurde inzwischen geändert. Bitte neu laden.");
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Ok(await LoadDetailAsync(db, id, ct));
}
// ---------- Aufgaben ----------
private static async Task<IResult> AddItemAsync(Guid id, LearningItemInput request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.Count >= LearningSet.MaxItems)
{
return EndpointHelpers.Conflict($"Ein Blatt kann höchstens {LearningSet.MaxItems} Aufgaben haben.");
}
var item = LearningValidation.ToItem(request);
item.SortOrder = set.Items.Count == 0 ? 0 : set.Items.Max(i => i.SortOrder) + 1;
item.SetId = set.Id;
db.LearningItems.Add(item); // explizit: Entitäten mit gesetzter Id sind für EF sonst „vorhanden“ (Modified)
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> UpdateItemAsync(
Guid id, Guid itemId, UpdateLearningItemRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(item.RowVersion))
{
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
var fields = LearningValidation.ToItem(request);
item.Question = fields.Question;
item.AnswerType = fields.AnswerType;
item.CorrectAnswer = fields.CorrectAnswer;
item.NumberTolerance = fields.NumberTolerance;
item.Options = fields.Options;
item.Hint = fields.Hint;
item.Explanation = fields.Explanation;
item.Difficulty = fields.Difficulty;
item.Points = fields.Points;
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> DeleteItemAsync(Guid id, Guid itemId, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.Ok(await LoadDetailAsync(db, id, ct)); // idempotent
}
db.LearningItems.Remove(item); // Soft Delete über den Interceptor
var order = 0;
foreach (var remaining in set.Items.Where(i => i.Id != itemId).OrderBy(i => i.SortOrder))
{
remaining.SortOrder = order++;
}
return await SaveItemsAsync(db, set, notifier, ct);
}
private static async Task<IResult> ReorderItemsAsync(
Guid id, ReorderLearningItemsRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (request.ItemIds.Count != set.Items.Count || !set.Items.Select(i => i.Id).ToHashSet().SetEquals(request.ItemIds))
{
return EndpointHelpers.Conflict("Die Reihenfolge passt nicht zu den Aufgaben des Blatts. Bitte neu laden.");
}
for (var index = 0; index < request.ItemIds.Count; index++)
{
set.Items.Single(i => i.Id == request.ItemIds[index]).SortOrder = index;
}
return await SaveItemsAsync(db, set, notifier, ct);
}
/// <summary>Blatt mit Aufgaben laden; Aufgaben sind nur änderbar, solange es keinen Versuch gibt.</summary>
private static async Task<(LearningSet? Set, IResult? Error)> LoadEditableAsync(AppDbContext db, Guid id, CancellationToken ct)
{
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
if (set is null)
{
return (null, TypedResults.NotFound());
}
return LearningRules.CanEditItems(set.Attempts.Count)
? (set, null)
: (null, EndpointHelpers.Conflict("Nach dem ersten Versuch lassen sich die Aufgaben nicht mehr ändern."));
}
private static async Task<IResult> SaveItemsAsync(AppDbContext db, LearningSet set, LearningNotifier notifier, CancellationToken ct)
{
set.ItemsRevision++; // ändert die Zeile des Blatts: kollidiert mit gleichzeitigem Start eines Versuchs oder Bearbeiten
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
db.ChangeTracker.Clear();
return TypedResults.Conflict(await ConflictDetailAsync(db, set.Id, ct));
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Ok(await LoadDetailAsync(db, set.Id, ct));
}
// ---------- Prüfung ----------
private static async Task<Ok<List<LearningReviewQueueItemDto>>> ReviewQueueAsync(AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var attempts = await db.LearningAttempts.AsNoTracking().Include(a => a.Answers)
.Where(a => a.Status == LearningAttemptStatus.Submitted)
.Where(a => db.LearningSets.Any(s => s.Id == a.SetId)) // gelöschte Blätter (Soft Delete) nicht anzeigen
.OrderBy(a => a.SubmittedAt)
.Take(MaxReviewQueue)
.ToListAsync(ct);
var setIds = attempts.Select(a => a.SetId).Distinct().ToList();
var sets = await db.LearningSets.AsNoTracking().Include(s => s.Items).Where(s => setIds.Contains(s.Id)).AsSplitQuery().ToDictionaryAsync(s => s.Id, ct);
var names = await LearningAccess.DisplayNamesAsync(db, attempts.Select(a => a.UserId), ct);
var rows = new List<LearningReviewQueueItemDto>();
foreach (var attempt in attempts)
{
var set = sets[attempt.SetId];
var items = set.Items.ToDictionary(i => i.Id);
var score = LearningMapper.ScoreOf(attempt, items);
rows.Add(new LearningReviewQueueItemDto(
attempt.Id, set.Id, set.Title, set.Subject, set.Mode, attempt.UserId, names.GetValueOrDefault(attempt.UserId, string.Empty),
attempt.Number, attempt.SubmittedAt ?? attempt.StartedAt, attempt.AutoSubmitted, score.OpenCount, score.Earned,
LearningMapper.TotalPointsOf(attempt, items)));
}
return TypedResults.Ok(rows);
}
private static async Task<Results<Ok<LearningAttemptReviewDto>, NotFound, ProblemHttpResult>> GetAttemptAsync(
Guid attemptId, AppDbContext db, LearningAttemptService service, CancellationToken ct)
{
await service.FinalizeExpiredAsync(null, ct);
var loaded = await LoadAttemptAsync(db, attemptId, tracking: false, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
if (loaded.Attempt.Status == LearningAttemptStatus.InProgress)
{
return EndpointHelpers.Conflict("Der Versuch läuft noch und wurde nicht abgegeben.");
}
return TypedResults.Ok(await ToReviewDtoAsync(db, loaded.Attempt, loaded.Set, ct));
}
private static async Task<IResult> ReviewAnswerAsync(
Guid attemptId, Guid itemId, ReviewAnswerRequest request, AppDbContext db, LearningAttemptService service, TimeProvider time, CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null || loaded.Set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
var attempt = loaded.Attempt;
if (attempt.Status != LearningAttemptStatus.Submitted)
{
return EndpointHelpers.Conflict(attempt.Status == LearningAttemptStatus.Reviewed
? "Der Versuch ist bereits bestätigt. Bitte zuerst erneut öffnen."
: "Der Versuch wurde noch nicht abgegeben.");
}
var (points, error) = LearningRules.NormalizeParentVerdict(request.Verdict, request.AwardedPoints, item.Points);
if (error is not null)
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [nameof(ReviewAnswerRequest.AwardedPoints)] = [error] }, title: "Eingaben prüfen");
}
var now = time.GetUtcNow();
var answer = attempt.Answers.SingleOrDefault(a => a.ItemId == itemId);
if (answer is null)
{
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = itemId };
db.LearningAnswers.Add(answer);
}
answer.Verdict = request.Verdict;
answer.AwardedPoints = points;
answer.Grader = LearningGrader.Parent;
answer.Reason = string.IsNullOrWhiteSpace(request.Reason) ? null : request.Reason.Trim();
answer.GradedAt = now;
attempt.LastActivityAt = now;
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Bewertung speichern");
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
return TypedResults.Ok(await ToReviewDtoAsync(db, attempt, loaded.Set, ct));
}
private static async Task<IResult> ConfirmAsync(
Guid attemptId,
ConfirmAttemptRequest request,
ClaimsPrincipal user,
AppDbContext db,
LearningAttemptService service,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
var (attempt, set) = (loaded.Attempt, loaded.Set);
if (attempt.Status == LearningAttemptStatus.Reviewed)
{
return TypedResults.Ok(await ResultAsync(db, attempt, set, 0, ct)); // idempotent
}
if (attempt.Status != LearningAttemptStatus.Submitted)
{
return EndpointHelpers.Conflict("Der Versuch wurde noch nicht abgegeben.");
}
var items = set.Items.ToDictionary(i => i.Id);
var score = LearningMapper.ScoreOf(attempt, items);
if (score.OpenCount > 0)
{
return EndpointHelpers.Conflict($"Noch {score.OpenCount} Antwort(en) ohne Bewertung. Bitte zuerst alle Antworten bewerten.");
}
var now = service.Now;
var total = LearningMapper.TotalPointsOf(attempt, items);
attempt.Status = LearningAttemptStatus.Reviewed;
attempt.ReviewedAt = now;
attempt.ReviewedBy = user.UserId();
attempt.EarnedPoints = score.Earned;
attempt.TotalItemPoints = total;
attempt.Grade = set.Mode == LearningMode.Exam ? LearningGrades.GradeFor(score.Earned, total, LearningMapper.Thresholds(set)) : null;
attempt.ReviewNote = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
attempt.LastActivityAt = now;
LearningAttemptService.RefreshStatus(set);
var (delta, credited) = await service.SyncPointsAsync(set, $"Rückbuchung: Lernblatt „{set.Title}“", ct);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Bestätigen");
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
await PublishAsync(set, delta, LearningChangeKind.Reviewed, user, notifier, taskNotifier, audit, db, ct);
return TypedResults.Ok(await ResultAsync(db, attempt, set, delta, ct, credited));
}
private static async Task<IResult> ReopenAsync(
Guid attemptId,
ClaimsPrincipal user,
AppDbContext db,
LearningAttemptService service,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
CancellationToken ct)
{
var loaded = await LoadAttemptAsync(db, attemptId, tracking: true, ct);
if (loaded is null)
{
return TypedResults.NotFound();
}
var (attempt, set) = (loaded.Attempt, loaded.Set);
if (attempt.Status == LearningAttemptStatus.Submitted)
{
return TypedResults.Ok(await ResultAsync(db, attempt, set, 0, ct)); // idempotent
}
if (attempt.Status != LearningAttemptStatus.Reviewed)
{
return EndpointHelpers.Conflict("Nur bestätigte Versuche lassen sich erneut öffnen.");
}
attempt.Status = LearningAttemptStatus.Submitted;
attempt.ReviewedAt = null;
attempt.ReviewedBy = null;
attempt.EarnedPoints = null;
attempt.TotalItemPoints = null;
attempt.Grade = null;
attempt.LastActivityAt = service.Now;
LearningAttemptService.RefreshStatus(set);
var (delta, credited) = await service.SyncPointsAsync(set, $"Rückbuchung: Lernblatt „{set.Title}“ wieder geöffnet", ct);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Erneut öffnen");
return EndpointHelpers.Conflict("Der Versuch wurde inzwischen geändert. Bitte neu laden.");
}
await PublishAsync(set, delta, LearningChangeKind.SetChanged, user, notifier, taskNotifier, audit, db, ct);
return TypedResults.Ok(await ResultAsync(db, attempt, set, delta, ct, credited));
}
// ---------- Hilfen ----------
private sealed record LoadedAttempt(LearningAttempt Attempt, LearningSet Set);
/// <summary>Versuch mit Antworten und das zugehörige (nicht gelöschte) Blatt mit Aufgaben und allen Versuchen.</summary>
private static async Task<LoadedAttempt?> LoadAttemptAsync(AppDbContext db, Guid attemptId, bool tracking, CancellationToken ct)
{
var setId = await db.LearningAttempts.Where(a => a.Id == attemptId).Select(a => (Guid?)a.SetId).SingleOrDefaultAsync(ct);
if (setId is null)
{
return null;
}
var setQuery = db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).ThenInclude(a => a.Answers).AsSplitQuery();
if (!tracking)
{
setQuery = setQuery.AsNoTracking();
}
var set = await setQuery.SingleOrDefaultAsync(s => s.Id == setId, ct);
var attempt = set?.Attempts.SingleOrDefault(a => a.Id == attemptId);
return set is null || attempt is null ? null : new LoadedAttempt(attempt, set);
}
private static async Task<LearningAttemptReviewDto> ToReviewDtoAsync(AppDbContext db, LearningAttempt attempt, LearningSet set, CancellationToken ct)
{
var names = await LearningAccess.DisplayNamesAsync(db, [attempt.UserId], ct);
var credited = (await CreditedAsync(db, [set.Id], ct)).GetValueOrDefault(set.Id);
return LearningMapper.ToReviewDto(attempt, set, names.GetValueOrDefault(attempt.UserId, string.Empty), credited);
}
private static async Task<LearningReviewResultDto> ResultAsync(
AppDbContext db, LearningAttempt attempt, LearningSet set, int delta, CancellationToken ct, int? credited = null)
{
var dto = await ToReviewDtoAsync(db, attempt, set, ct);
return new LearningReviewResultDto(credited is null ? dto : dto with { PointsCredited = credited.Value }, delta, credited ?? dto.PointsCredited);
}
private static async Task PublishAsync(
LearningSet set,
int delta,
LearningChangeKind kind,
ClaimsPrincipal user,
LearningNotifier notifier,
TaskNotifier taskNotifier,
IAuditLogger audit,
AppDbContext db,
CancellationToken ct)
{
if (delta != 0)
{
var names = await LearningAccess.DisplayNamesAsync(db, [set.TargetUserId], ct);
var child = names.GetValueOrDefault(set.TargetUserId, string.Empty);
await audit.WriteAsync(
user.ActorName(),
delta > 0 ? AuditActions.LearningPointsCredited : AuditActions.LearningPointsReversed,
"LearningSet",
set.Id.ToString(),
set.Title,
$"{(delta > 0 ? "+" : string.Empty)}{delta} Punkte für {child}",
ct);
await taskNotifier.PointsChangedAsync(ct);
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, kind, ct);
}
private static async Task<Dictionary<Guid, int>> CreditedAsync(AppDbContext db, IEnumerable<Guid> setIds, CancellationToken ct)
{
var ids = setIds.Distinct().Cast<Guid?>().ToList();
return await db.PointsLedger.AsNoTracking()
.Where(p => ids.Contains(p.LearningSetId))
.GroupBy(p => p.LearningSetId!.Value)
.Select(g => new { Id = g.Key, Points = g.Sum(p => p.Points) })
.ToDictionaryAsync(x => x.Id, x => x.Points, ct);
}
internal static async Task<LearningSetDetailDto?> LoadDetailAsync(AppDbContext db, Guid id, CancellationToken ct)
{
var set = await db.LearningSets.AsNoTracking().Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == id, ct);
return set is null ? null : LearningMapper.ToDetail(set, (await CreditedAsync(db, [id], ct)).GetValueOrDefault(id));
}
private static async Task<LearningSetDetailDto> ConflictDetailAsync(AppDbContext db, Guid id, CancellationToken ct) =>
(await LoadDetailAsync(db, id, ct))!;
/// <summary>Das Ziel muss ein aktives Kind sein (Rolle „Kind“).</summary>
private static async Task<ValidationProblem?> CheckTargetAsync(AppDbContext db, Guid userId, CancellationToken ct)
{
var roles = await db.GetRoleMapAsync(ct);
var active = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive, ct);
return active && roles.GetValueOrDefault(userId) == Roles.Child
? null
: TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [nameof(ILearningSetFields.TargetUserId)] = ["Übungsblätter lassen sich nur Kindern zuweisen."] },
title: "Eingaben prüfen");
}
private static void ApplySet(LearningSet set, ILearningSetFields fields)
{
set.Title = fields.Title.Trim();
set.Subject = fields.Subject;
set.GradeLevel = fields.GradeLevel;
set.TargetUserId = fields.TargetUserId;
set.DueDate = fields.DueDate;
set.Mode = fields.Mode;
set.MaxAttempts = fields.MaxAttempts;
set.TimeLimitMinutes = fields.TimeLimitMinutes;
set.ShowSolutionsAfterSubmit = fields.ShowSolutionsAfterSubmit;
set.ShuffleItems = fields.ShuffleItems;
set.TotalPoints = fields.TotalPoints;
LearningGrades.TryParse(fields.GradeThresholds, out var thresholds, out _);
set.GradeThresholds = LearningGrades.Format(thresholds);
}
/// <summary>Nach dem ersten Versuch sind Ziel, Modus, Mischen, Punkte und Notenschlüssel fest (sonst ändern sich Wertungen nachträglich).</summary>
private static string? LockedFieldChanged(LearningSet set, ILearningSetFields fields)
{
LearningGrades.TryParse(fields.GradeThresholds, out var thresholds, out _);
if (fields.TargetUserId != set.TargetUserId)
{
return "Das Kind";
}
if (fields.Mode != set.Mode)
{
return "Der Modus";
}
if (fields.ShuffleItems != set.ShuffleItems)
{
return "Das Mischen der Aufgaben";
}
if (fields.TotalPoints != set.TotalPoints)
{
return "Die Gesamtpunktzahl";
}
return LearningGrades.Format(thresholds) != set.GradeThresholds ? "Der Notenschlüssel" : null;
}
}
@@ -0,0 +1,323 @@
using System.Security.Claims;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
/// <summary>
/// Kinderseite der Lernaufgaben (<c>LearningSolve</c>): eigene Blätter, Versuch starten, Antworten speichern, abgeben, Ergebnis (ADR-0021).
/// Fremde Blätter und Versuche antworten mit 404; Entwürfe sind unsichtbar. Alle Antworten entstehen über <see cref="LearningMapper"/>
/// (Sichtbarkeit nach <see cref="LearningRules.VisibilityFor"/>).
/// </summary>
internal static class LearningSolveEndpoints
{
private const int MaxAttempts = 3;
public static void Map(RouteGroupBuilder group)
{
var solve = group.MapGroup("/my").RequirePermission(Permission.LearningSolve);
solve.MapGet("/", ListAsync);
solve.MapPost("/{setId:guid}/attempts", StartAsync);
solve.MapGet("/attempts/{attemptId:guid}", GetAttemptAsync);
solve.MapPut("/attempts/{attemptId:guid}/answers/{itemId:guid}", SaveAnswerAsync).Validate<SaveAnswerRequest>();
solve.MapPost("/attempts/{attemptId:guid}/submit", SubmitAsync);
}
private static async Task<Ok<List<MyLearningSetDto>>> ListAsync(
ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, LearningAttemptService service, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
await service.FinalizeExpiredAsync(actor.UserId, ct);
var me = actor.UserId;
var sets = await db.LearningSets.AsNoTracking()
.Include(s => s.Items)
.Include(s => s.Attempts.Where(a => a.UserId == me)).ThenInclude(a => a.Answers)
.Where(s => s.TargetUserId == me && s.Status != LearningSetStatus.Draft)
.AsSplitQuery()
.ToListAsync(ct);
sets = sets.Where(s => LearningRules.CanSolve(actor, s.TargetUserId, s.Status)).ToList();
var ids = sets.Select(s => s.Id).Cast<Guid?>().ToList();
var credited = await db.PointsLedger.AsNoTracking()
.Where(p => ids.Contains(p.LearningSetId))
.GroupBy(p => p.LearningSetId!.Value)
.Select(g => new { Id = g.Key, Points = g.Sum(p => p.Points) })
.ToDictionaryAsync(x => x.Id, x => x.Points, ct);
var rows = sets
.OrderBy(s => s.DueDate ?? DateOnly.MaxValue)
.ThenBy(s => s.Title, StringComparer.CurrentCultureIgnoreCase)
.Select(set =>
{
var items = set.Items.ToDictionary(i => i.Id);
var statuses = set.Attempts.Select(a => a.Status).ToList();
return new MyLearningSetDto(
set.Id,
set.Title,
set.Subject,
set.GradeLevel,
set.DueDate,
set.Status,
set.Mode,
set.TimeLimitMinutes,
set.Items.Count,
set.TotalPoints,
set.MaxAttempts,
set.Attempts.Count,
LearningRules.CanStartAttempt(set.Status, set.MaxAttempts, statuses) is AttemptStart.Ok or AttemptStart.Running,
set.Attempts.FirstOrDefault(a => a.Status == LearningAttemptStatus.InProgress)?.Id,
credited.GetValueOrDefault(set.Id),
set.Attempts
.Where(a => a.Status != LearningAttemptStatus.InProgress)
.OrderByDescending(a => a.Number)
.Select(a => LearningMapper.ToResult(a, items, set))
.ToList());
})
.ToList();
return TypedResults.Ok(rows);
}
private static async Task<IResult> StartAsync(
Guid setId,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
LearningNotifier notifier,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
await service.FinalizeExpiredAsync(actor.UserId, ct); // abgelaufener, nie abgegebener Versuch zählt als verbraucht
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).AsSplitQuery().SingleOrDefaultAsync(s => s.Id == setId, ct);
if (set is null || !LearningRules.CanSolve(actor, set.TargetUserId, set.Status))
{
return TypedResults.NotFound();
}
var mine = set.Attempts.Where(a => a.UserId == actor.UserId).ToList();
var now = service.Now;
switch (LearningRules.CanStartAttempt(set.Status, set.MaxAttempts, mine.Select(a => a.Status).ToList()))
{
case AttemptStart.NoAttemptsLeft:
return EndpointHelpers.Conflict("Alle erlaubten Versuche sind aufgebraucht.");
case AttemptStart.NotAssigned:
return TypedResults.NotFound();
case AttemptStart.Running:
var running = await LoadAsync(db, mine.First(a => a.Status == LearningAttemptStatus.InProgress).Id, actor, ct);
return TypedResults.Ok(LearningMapper.ToAttemptDto(running!.Attempt, running.Set, now));
}
var created = new LearningAttempt
{
SetId = set.Id,
UserId = actor.UserId,
Number = mine.Count == 0 ? 1 : mine.Max(a => a.Number) + 1,
StartedAt = now,
DeadlineAt = LearningRules.DeadlineFor(now, set.TimeLimitMinutes),
LastActivityAt = now,
};
created.ItemOrder = LearningRules.SerializeOrder(
LearningRules.OrderItems(set.Items.OrderBy(i => i.SortOrder).Select(i => i.Id).ToList(), set.ShuffleItems, created.Id));
db.LearningAttempts.Add(created); // explizit: Entitäten mit gesetzter Id sind für EF sonst „vorhanden“ (Modified)
LearningAttemptService.RefreshStatus(set);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Versuch starten");
// Doppelklick/zweiter Tab: der andere Start hat gewonnen (eindeutiger Index) – beim nächsten Durchlauf wird er fortgesetzt.
continue;
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.SetChanged, ct);
return TypedResults.Created($"/api/learning/my/attempts/{created.Id}", LearningMapper.ToAttemptDto(created, set, now));
}
return EndpointHelpers.Conflict("Der Versuch konnte nicht gestartet werden. Bitte erneut versuchen.");
}
private static async Task<Results<Ok<LearningAttemptDto>, NotFound>> GetAttemptAsync(
Guid attemptId, ClaimsPrincipal user, AppDbContext db, ISettingsProvider settingsProvider, LearningAttemptService service, CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
if (await service.TryFinalizeAsync(attemptId, ct))
{
db.ChangeTracker.Clear();
}
return await LoadAsync(db, attemptId, actor, ct) is { } loaded
? TypedResults.Ok(LearningMapper.ToAttemptDto(loaded.Attempt, loaded.Set, service.Now))
: TypedResults.NotFound();
}
private static async Task<IResult> SaveAnswerAsync(
Guid attemptId,
Guid itemId,
SaveAnswerRequest request,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
var value = string.IsNullOrWhiteSpace(request.Value) ? null : request.Value.Trim();
// Zustandsbasiert: bei gleichzeitiger Änderung (z. B. Abgabe) neu laden und erneut bewerten.
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
if (await LoadAsync(db, attemptId, actor, ct) is not { } loaded)
{
return TypedResults.NotFound();
}
var (entity, set) = (loaded.Attempt, loaded.Set);
var now = service.Now;
if (entity.Status != LearningAttemptStatus.InProgress)
{
return EndpointHelpers.Conflict("Der Versuch ist bereits abgegeben.");
}
if (!LearningRules.AcceptsSaves(entity.DeadlineAt, now))
{
db.ChangeTracker.Clear();
await service.TryFinalizeAsync(attemptId, ct); // Antworten bis zum Ablauf zählen; der Versuch wird automatisch abgegeben
return EndpointHelpers.Conflict("Die Zeit ist abgelaufen. Der Versuch wurde abgegeben.");
}
if (!LearningRules.ParseOrder(entity.ItemOrder).Contains(itemId) || set.Items.SingleOrDefault(i => i.Id == itemId) is not { } item)
{
return TypedResults.NotFound();
}
var answer = entity.Answers.SingleOrDefault(a => a.ItemId == itemId);
if (answer is null || answer.Value != value)
{
if (answer is null)
{
answer = new LearningAnswer { AttemptId = entity.Id, ItemId = itemId };
db.LearningAnswers.Add(answer);
}
answer.Value = value;
answer.AnsweredAt = value is null ? null : now;
answer.Grader = LearningGrader.Auto;
if (value is null)
{
answer.Verdict = LearningVerdict.Open;
answer.AwardedPoints = 0;
answer.Reason = null;
answer.GradedAt = null;
}
else
{
var result = LearningGrading.Grade(item, value);
answer.Verdict = result.Verdict;
answer.AwardedPoints = result.AwardedPoints;
answer.Reason = result.Reason;
answer.GradedAt = now;
}
entity.LastActivityAt = now; // schützt per RowVersion vor Speichern während der Abgabe
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Antwort speichern");
continue; // Abgabe oder zweiter Tab gleichzeitig – Zustand neu lesen
}
}
var visibility = LearningRules.VisibilityFor(set.Mode, LearningAttemptStatus.InProgress, set.ShowSolutionsAfterSubmit);
return TypedResults.Ok(new SaveAnswerResultDto(
itemId,
value is not null,
visibility.ShowVerdict && value is not null ? answer.Verdict : null,
entity.DeadlineAt,
now));
}
return EndpointHelpers.Conflict("Die Antwort konnte nicht gespeichert werden. Bitte erneut versuchen.");
}
private static async Task<IResult> SubmitAsync(
Guid attemptId,
ClaimsPrincipal user,
AppDbContext db,
ISettingsProvider settingsProvider,
LearningAttemptService service,
LearningNotifier notifier,
CancellationToken ct)
{
var actor = await user.LearningActorAsync(settingsProvider);
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
db.ChangeTracker.Clear();
if (await LoadAsync(db, attemptId, actor, ct) is not { } loaded)
{
return TypedResults.NotFound();
}
var (entity, set) = (loaded.Attempt, loaded.Set);
var now = service.Now;
if (entity.Status != LearningAttemptStatus.InProgress)
{
return TypedResults.Ok(LearningMapper.ToAttemptDto(entity, set, now)); // idempotent
}
service.Submit(entity, set, now);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex)
{
service.LogConflict(ex, "Abgabe");
continue; // gleichzeitig gespeichert/abgegeben
}
await notifier.ChangedAsync(set.Id, set.TargetUserId, LearningChangeKind.Submitted, ct);
return TypedResults.Ok(LearningMapper.ToAttemptDto(entity, set, now));
}
return EndpointHelpers.Conflict("Die Abgabe konnte nicht gespeichert werden. Bitte erneut versuchen.");
}
private sealed record LoadedAttempt(LearningAttempt Attempt, LearningSet Set);
/// <summary>Eigenen Versuch samt Blatt laden. Fremde Versuche, gelöschte Blätter und Entwürfe ergeben <c>null</c> (→ 404).</summary>
private static async Task<LoadedAttempt?> LoadAsync(AppDbContext db, Guid attemptId, LearningActor actor, CancellationToken ct)
{
var setId = await db.LearningAttempts
.Where(a => a.Id == attemptId && a.UserId == actor.UserId)
.Select(a => (Guid?)a.SetId)
.SingleOrDefaultAsync(ct);
if (setId is null)
{
return null;
}
var set = await db.LearningSets.Include(s => s.Items).Include(s => s.Attempts).ThenInclude(a => a.Answers).AsSplitQuery()
.SingleOrDefaultAsync(s => s.Id == setId, ct);
var attempt = set?.Attempts.SingleOrDefault(a => a.Id == attemptId);
return set is null || attempt is null || !LearningRules.CanSolve(actor, set.TargetUserId, set.Status) ? null : new LoadedAttempt(attempt, set);
}
}
@@ -0,0 +1,221 @@
using System.Security.Claims;
using FamilyPlanner.Api.Hubs;
using FamilyPlanner.Api.Infrastructure;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Infrastructure.Identity;
using FamilyPlanner.Infrastructure.Persistence;
using FamilyPlanner.Infrastructure.Settings;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace FamilyPlanner.Api.Features.Learning;
internal static class LearningAccess
{
/// <summary>Der Benutzer mit seinen Lern-Rechten für objektbezogene Prüfungen (ADR-0021).</summary>
public static async Task<LearningActor> LearningActorAsync(this ClaimsPrincipal user, ISettingsProvider settingsProvider)
{
var settings = await settingsProvider.GetAsync();
return LearningActor.From(user.UserId() ?? Guid.Empty, user.FindAll(ClaimTypes.Role).Select(c => c.Value), settings.ToPermissionOptions());
}
public static async Task<Dictionary<Guid, string>> DisplayNamesAsync(AppDbContext db, IEnumerable<Guid> ids, CancellationToken ct)
{
var distinct = ids.Distinct().ToList();
var users = await db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => distinct.Contains(u.Id))
.Select(u => new { u.Id, u.DisplayName, u.UserName })
.ToListAsync(ct);
return users.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
}
}
/// <summary>
/// Sendet Lern-Ereignisse – <b>ohne Inhalte</b> – nur an die Zielperson und alle Eltern (Gruppe <c>user:{id}</c>, wie TaskNotifier/ChatNotifier).
/// Die Clients laden über die geschützten Endpunkte nach; andere Kinder und Gäste erfahren nichts.
/// </summary>
public sealed class LearningNotifier(AppDbContext db, IHubContext<LiveHub> hub, ISettingsProvider settingsProvider, TimeProvider timeProvider)
{
public async Task ChangedAsync(Guid setId, Guid targetUserId, LearningChangeKind kind, CancellationToken ct, Guid? previousTargetUserId = null)
{
var groups = (await ParentIdsAsync(ct)).Append(targetUserId);
if (previousTargetUserId is { } previous)
{
groups = groups.Append(previous);
}
var recipients = groups.Distinct().Select(id => LiveHub.UserGroup(id.ToString())).ToList();
await hub.Clients.Groups(recipients).SendAsync(LearningEvents.Changed, new LearningChangedDto(setId, kind), ct);
}
/// <summary>Aktive Benutzer mit Recht <c>LearningManage</c>.</summary>
private async Task<List<Guid>> ParentIdsAsync(CancellationToken ct)
{
var options = (await settingsProvider.GetAsync(ct)).ToPermissionOptions();
var roles = await db.GetRoleMapAsync(ct);
var now = timeProvider.GetUtcNow();
var ids = await db.Users.AsNoTracking()
.Where(u => u.IsActive && (u.ExpiresAt == null || u.ExpiresAt > now))
.Select(u => u.Id)
.ToListAsync(ct);
return ids
.Where(id => roles.TryGetValue(id, out var role) && PermissionMatrix.For(Permission.LearningManage, role, options) == AccessLevel.Full)
.ToList();
}
}
/// <summary>Wandelt Entitäten in DTOs. Kinder-DTOs entstehen ausschließlich hier über <see cref="LearningRules.VisibilityFor"/>.</summary>
internal static class LearningMapper
{
public static LearningSetDto ToDto(LearningSet s, int itemCount, int attemptCount, int pendingReview, int pointsCredited) => new(
s.Id, s.Title, s.Subject, s.GradeLevel, s.TargetUserId, s.DueDate, s.Status, s.Mode, s.MaxAttempts, s.TimeLimitMinutes,
s.ShowSolutionsAfterSubmit, s.ShuffleItems, s.TotalPoints, s.GradeThresholds, s.Source, s.AiPrompt, s.AiProvider, s.AiModel,
s.AssignedAt, itemCount, attemptCount, pendingReview, pointsCredited, s.CreatedAt, s.UpdatedAt, s.RowVersion);
/// <summary>Für geladene Blätter mit Aufgaben und Versuchen.</summary>
public static LearningSetDto ToDto(LearningSet s, int pointsCredited) => ToDto(
s, s.Items.Count, s.Attempts.Count, s.Attempts.Count(a => a.Status == LearningAttemptStatus.Submitted), pointsCredited);
public static LearningItemDto ToDto(LearningItem i) => new(
i.Id, i.SortOrder, i.Question, i.AnswerType, i.CorrectAnswer, i.NumberTolerance, [.. i.Options], i.Hint, i.Explanation,
i.Difficulty, i.Points, i.RowVersion);
public static LearningSetDetailDto ToDetail(LearningSet s, int pointsCredited) => new(
ToDto(s, pointsCredited),
s.Items.OrderBy(i => i.SortOrder).Select(ToDto).ToList(),
s.Attempts.OrderBy(a => a.Number).Select(a => new LearningAttemptSummaryDto(
a.Id, a.Number, a.Status, a.StartedAt, a.SubmittedAt, a.AutoSubmitted, a.EarnedPoints, a.TotalItemPoints, a.Grade)).ToList());
public static LearningScore ScoreOf(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items) =>
LearningRules.Score(attempt.Answers
.Where(a => items.ContainsKey(a.ItemId))
.Select(a => new ScoredAnswer(items[a.ItemId].Points, a.Verdict, a.AwardedPoints)));
/// <summary>Gesamtpunkte der Aufgaben, die zum Versuch gehören (auch wenn noch nicht beantwortet).</summary>
public static int TotalPointsOf(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items) =>
LearningRules.ParseOrder(attempt.ItemOrder).Where(items.ContainsKey).Sum(id => items[id].Points);
public static MyLearningResultDto ToResult(LearningAttempt attempt, IReadOnlyDictionary<Guid, LearningItem> items, LearningSet set)
{
var score = ScoreOf(attempt, items);
var final = attempt.Status == LearningAttemptStatus.Reviewed;
var total = TotalPointsOf(attempt, items);
return new MyLearningResultDto(
attempt.Id,
attempt.Number,
attempt.Status,
attempt.SubmittedAt,
attempt.AutoSubmitted,
final ? attempt.EarnedPoints ?? score.Earned : score.Earned,
final ? attempt.TotalItemPoints ?? total : total,
score.OpenCount,
final && set.Mode == LearningMode.Exam ? attempt.Grade : null,
final);
}
/// <summary>Versuch aus Kindersicht: Lösungen, Hinweise und Bewertung nur, wenn die Regeln es für Modus und Status erlauben.</summary>
public static LearningAttemptDto ToAttemptDto(LearningAttempt attempt, LearningSet set, DateTimeOffset now)
{
var visibility = LearningRules.VisibilityFor(set.Mode, attempt.Status, set.ShowSolutionsAfterSubmit);
var items = set.Items.ToDictionary(i => i.Id);
var answers = attempt.Answers.ToDictionary(a => a.ItemId);
var rows = new List<LearningAttemptItemDto>();
var position = 0;
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
answers.TryGetValue(id, out var answer);
LearningItemFeedbackDto? feedback = null;
var answered = answer?.AnsweredAt is not null;
if (visibility.ShowVerdict && answer is not null && (attempt.Status != LearningAttemptStatus.InProgress || answered))
{
feedback = new LearningItemFeedbackDto(
answer.Verdict,
answer.AwardedPoints,
// Nur Kommentare der Eltern; Meldungen der Automatik („Keine Antwort.“) stehen schon im Ergebnis.
attempt.Status != LearningAttemptStatus.InProgress && answer.Grader == LearningGrader.Parent ? answer.Reason : null,
visibility.ShowSolution ? item.CorrectAnswer : null,
visibility.ShowSolution ? item.Explanation : null);
}
else if (visibility.ShowSolution && attempt.Status != LearningAttemptStatus.InProgress)
{
feedback = new LearningItemFeedbackDto(LearningVerdict.Open, 0, null, item.CorrectAnswer, item.Explanation);
}
rows.Add(new LearningAttemptItemDto(
id,
position++,
item.Question,
item.AnswerType,
[.. item.Options],
item.Points,
item.Difficulty,
answer?.Value,
visibility.ShowHint ? item.Hint : null,
feedback));
}
return new LearningAttemptDto(
attempt.Id,
set.Id,
set.Title,
set.Subject,
set.Mode,
attempt.Number,
attempt.Status,
attempt.StartedAt,
attempt.DeadlineAt,
now,
attempt.AutoSubmitted,
rows,
attempt.Status == LearningAttemptStatus.InProgress ? null : ToResult(attempt, items, set),
attempt.RowVersion);
}
/// <summary>Versuch aus Elternsicht (Frage, Musterlösung, Kinderantwort, Bewertung).</summary>
public static LearningAttemptReviewDto ToReviewDto(LearningAttempt attempt, LearningSet set, string childName, int pointsCredited)
{
var items = set.Items.ToDictionary(i => i.Id);
var answers = attempt.Answers.ToDictionary(a => a.ItemId);
var rows = new List<LearningReviewAnswerDto>();
var position = 0;
foreach (var id in LearningRules.ParseOrder(attempt.ItemOrder))
{
if (!items.TryGetValue(id, out var item))
{
continue;
}
answers.TryGetValue(id, out var answer);
rows.Add(new LearningReviewAnswerDto(
id, position++, item.Question, item.AnswerType, [.. item.Options], item.CorrectAnswer, item.Explanation, item.Points,
answer?.Value, answer?.Verdict ?? LearningVerdict.Open, answer?.Grader ?? LearningGrader.Auto, answer?.AwardedPoints ?? 0, answer?.Reason));
}
var score = ScoreOf(attempt, items);
var total = TotalPointsOf(attempt, items);
int? grade = set.Mode == LearningMode.Exam
? attempt.Status == LearningAttemptStatus.Reviewed
? attempt.Grade
: score.OpenCount == 0 ? LearningGrades.GradeFor(score.Earned, total, Thresholds(set)) : null
: null;
return new LearningAttemptReviewDto(
attempt.Id, set.Id, set.Title, set.Subject, set.Mode, set.TotalPoints, attempt.UserId, childName, attempt.Number, attempt.Status,
attempt.StartedAt, attempt.SubmittedAt, attempt.AutoSubmitted, rows, score.Earned, total, score.OpenCount, grade, pointsCredited,
attempt.ReviewNote, attempt.RowVersion);
}
public static IReadOnlyList<int> Thresholds(LearningSet set)
{
LearningGrades.TryParse(set.GradeThresholds, out var thresholds, out _);
return thresholds;
}
}
+5
View File
@@ -4,6 +4,7 @@ using FamilyPlanner.Api.Features.Auth;
using FamilyPlanner.Api.Features.Calendar;
using FamilyPlanner.Api.Features.Chat;
using FamilyPlanner.Api.Features.Dashboard;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Api.Features.Meals;
using FamilyPlanner.Api.Features.Push;
using FamilyPlanner.Api.Features.Shopping;
@@ -46,6 +47,9 @@ builder.Services.AddScoped<IAiProvider, OllamaProvider>();
builder.Services.AddScoped<IAiProvider, ClaudeProvider>();
builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
builder.Services.AddScoped<ChatNotifier>();
builder.Services.AddScoped<LearningNotifier>();
builder.Services.AddScoped<LearningAttemptService>();
builder.Services.AddHostedService<LearningExpiryService>();
builder.Services.AddSingleton<ChatTypingThrottle>();
// Web-Push (ADR-0014): ohne VAPID-Schlüssel bleibt der Versand inaktiv.
@@ -88,6 +92,7 @@ app.MapFeedEndpoints();
app.MapDashboardEndpoints();
app.MapChatEndpoints();
app.MapAiEndpoints();
app.MapLearningEndpoints();
app.MapHub<LiveHub>("/hubs/live");
app.Run();
@@ -39,6 +39,8 @@ public static class AuditActions
public const string AiProviderCreated = "ai.provider_created";
public const string AiProviderUpdated = "ai.provider_updated";
public const string AiProviderDeleted = "ai.provider_deleted";
public const string LearningPointsCredited = "learning.points_credited";
public const string LearningPointsReversed = "learning.points_reversed";
public static string DisplayName(string action) => action switch
{
@@ -50,6 +52,8 @@ public static class AuditActions
AiProviderCreated => "KI-Anbieter angelegt",
AiProviderUpdated => "KI-Anbieter geändert",
AiProviderDeleted => "KI-Anbieter gelöscht",
LearningPointsCredited => "Lernpunkte gutgeschrieben",
LearningPointsReversed => "Lernpunkte zurückgebucht",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -19,6 +19,8 @@ public enum Permission
FamilyChat,
DirectMessages,
AiUse,
LearningManage,
LearningSolve,
UserManagement,
CalendarManagement,
SettingsManagement,
@@ -68,6 +70,8 @@ public static class PermissionMatrix
[Permission.FamilyChat] = [F, F, F, N], // Gast: nur wenn freigegeben
[Permission.DirectMessages] = [F, F, R, N], // Kind: nur an Familienmitglieder
[Permission.AiUse] = [F, F, N, N], // KI-Funktionen nutzen: nur Eltern (ADR-0020)
[Permission.LearningManage] = [F, F, N, N], // Übungsblätter anlegen/zuweisen/prüfen (ADR-0021)
[Permission.LearningSolve] = [F, F, R, N], // Kind: nur eigene Blätter lösen (ADR-0021)
[Permission.UserManagement] = [F, N, N, N],
[Permission.CalendarManagement] = [F, N, N, N],
[Permission.SettingsManagement] = [F, N, N, N],
@@ -0,0 +1,279 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Status eines Übungsblatts. Nach dem Zuweisen wird er aus den Versuchen abgeleitet (<see cref="LearningRules.DeriveStatus"/>).</summary>
public enum LearningSetStatus
{
Draft = 0,
Assigned = 1,
InProgress = 2,
Submitted = 3,
Reviewed = 4,
}
/// <summary><see cref="Practice"/>: Sofort-Rückmeldung und Hinweise. <see cref="Exam"/>: Prüfungsregeln (ADR-0021 Punkt 13).</summary>
public enum LearningMode
{
Practice = 0,
Exam = 1,
}
/// <summary>Herkunft eines Blatts. <see cref="Ai"/> wird erst in Phase 10b gesetzt; die Felder dafür gibt es schon.</summary>
public enum LearningSource
{
Manual = 0,
Ai = 1,
}
/// <summary><see cref="Paper"/> (Foto/Papierlösung) ist nur als Wert vorgesehen und wird erst in Phase 10c freigeschaltet.</summary>
public enum LearningAnswerType
{
Number = 0,
Text = 1,
SingleChoice = 2,
Paper = 3,
}
/// <summary><see cref="Open"/>: noch nicht bewertet, wartet auf die Elternprüfung.</summary>
public enum LearningVerdict
{
Open = 0,
Correct = 1,
Partial = 2,
Wrong = 3,
}
public enum LearningGrader
{
Auto = 0,
Ai = 1,
Parent = 2,
}
public enum LearningAttemptStatus
{
InProgress = 0,
Submitted = 1,
Reviewed = 2,
}
/// <summary>Die Fächer des ersten Schnitts. Fach ist ein Text aus dieser Liste – weitere Fächer brauchen keine Modelländerung.</summary>
public static class LearningSubjects
{
public const string Math = "Mathe";
public const string German = "Deutsch";
public static IReadOnlyList<string> All { get; } = [Math, German];
public static bool IsValid(string? subject) => subject is not null && All.Contains(subject, StringComparer.Ordinal);
}
/// <summary>Ein Übungsblatt (ADR-0021). Eigene Entität, kein Teil der Familienaufgaben (ADR-0014).</summary>
public sealed class LearningSet : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int TitleMaxLength = 120;
public const int SubjectMaxLength = 40;
public const int MinGradeLevel = 1;
public const int MaxGradeLevel = 6;
public const int MaxItems = 60;
public const int AttemptsLimit = 20;
public const int MaxTimeLimitMinutes = 240;
public const int MaxTotalPoints = 100;
public const int GradeThresholdsMaxLength = 40;
public const int AiPromptMaxLength = 2000;
public const int AiProviderMaxLength = 100;
public string Title { get; set; } = string.Empty;
/// <summary>Text aus <see cref="LearningSubjects.All"/>.</summary>
public string Subject { get; set; } = LearningSubjects.Math;
public int GradeLevel { get; set; } = 1;
/// <summary>Das Kind, dem das Blatt zugewiesen ist (Rolle Kind; ohne Fremdschlüssel wie bei Aufgaben).</summary>
public Guid TargetUserId { get; set; }
/// <summary>Fälligkeitstag in der Familienzeitzone.</summary>
public DateOnly? DueDate { get; set; }
public LearningSetStatus Status { get; set; } = LearningSetStatus.Draft;
public LearningMode Mode { get; set; } = LearningMode.Practice;
/// <summary>Anzahl Versuche; 0 = unbegrenzt.</summary>
public int MaxAttempts { get; set; } = 1;
/// <summary>Zeitlimit je Versuch in Minuten; <c>null</c> = keins.</summary>
public int? TimeLimitMinutes { get; set; }
/// <summary>Musterlösung und Erklärung nach der Abgabe zeigen.</summary>
public bool ShowSolutionsAfterSubmit { get; set; }
/// <summary>Aufgabenreihenfolge je Versuch mischen (stabil je Versuch).</summary>
public bool ShuffleItems { get; set; }
/// <summary>Punkte, die das Blatt bei voller Punktzahl im Punktebuch ergibt; 0 = keine Punkte.</summary>
public int TotalPoints { get; set; }
/// <summary>Notenschlüssel für Prüfungen: Mindestprozent für die Noten 1 bis 5 (z. B. <c>92;81;67;50;30</c>); <c>null</c> = Standard.</summary>
public string? GradeThresholds { get; set; }
public LearningSource Source { get; set; } = LearningSource.Manual;
/// <summary>Nur bei <see cref="LearningSource.Ai"/> (Phase 10b): verwendeter Prompt, Anbieter und Modell.</summary>
public string? AiPrompt { get; set; }
public string? AiProvider { get; set; }
public string? AiModel { get; set; }
public DateTimeOffset? AssignedAt { get; set; }
/// <summary>Wird bei jeder Änderung an den Aufgaben erhöht und sperrt so gleichzeitige Bearbeitung (zusammen mit <see cref="RowVersion"/>).</summary>
public int ItemsRevision { get; set; }
public List<LearningItem> Items { get; set; } = [];
public List<LearningAttempt> Attempts { get; set; } = [];
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Eine Aufgabe eines Blatts.</summary>
public sealed class LearningItem : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int QuestionMaxLength = 1000;
public const int CorrectAnswerMaxLength = 200;
public const int HintMaxLength = 500;
public const int ExplanationMaxLength = 1000;
public const int OptionMaxLength = 100;
public const int MinOptions = 2;
public const int MaxOptions = 6;
public const int MinDifficulty = 1;
public const int MaxDifficulty = 3;
public const int MinPoints = 1;
public const int MaxPoints = 20;
public Guid SetId { get; set; }
public int SortOrder { get; set; }
public string Question { get; set; } = string.Empty;
public LearningAnswerType AnswerType { get; set; } = LearningAnswerType.Number;
/// <summary>
/// Musterlösung: Zahl (<see cref="LearningAnswerType.Number"/>), Text bzw. mehrere mit <c>|</c> getrennte Varianten
/// (<see cref="LearningAnswerType.Text"/>) oder der Index der richtigen Option (<see cref="LearningAnswerType.SingleChoice"/>).
/// </summary>
public string? CorrectAnswer { get; set; }
/// <summary>Absolute Toleranz für Zahlenantworten; <c>null</c> = exakt.</summary>
public decimal? NumberTolerance { get; set; }
/// <summary>Auswahloptionen (nur <see cref="LearningAnswerType.SingleChoice"/>).</summary>
public List<string> Options { get; set; } = [];
/// <summary>Hinweis für das Kind – nur im Übungsmodus sichtbar.</summary>
public string? Hint { get; set; }
/// <summary>Erklärung zur Musterlösung – nur nach der Abgabe und nur wenn „Lösungen zeigen“ aktiv ist.</summary>
public string? Explanation { get; set; }
/// <summary>1 = leicht, 2 = mittel, 3 = schwer.</summary>
public int Difficulty { get; set; } = 1;
public int Points { get; set; } = 1;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Ein Bearbeitungsversuch eines Kindes. Wiederholungen sind weitere Versuche (<see cref="Number"/> zählt hoch).</summary>
public sealed class LearningAttempt : AuditableEntity, IHasRowVersion
{
public const int NoteMaxLength = 500;
public const int ItemOrderMaxLength = 2400;
public Guid SetId { get; set; }
public Guid UserId { get; set; }
/// <summary>1, 2, 3 … je Blatt und Kind.</summary>
public int Number { get; set; }
public LearningAttemptStatus Status { get; set; } = LearningAttemptStatus.InProgress;
public DateTimeOffset StartedAt { get; set; }
/// <summary>Ablauf des Zeitlimits (vom Server beim Start festgelegt); <c>null</c> = kein Limit.</summary>
public DateTimeOffset? DeadlineAt { get; set; }
/// <summary>Letzte Speicherung einer Antwort. Das Ändern schützt per <see cref="RowVersion"/> vor Speichern während der Abgabe.</summary>
public DateTimeOffset LastActivityAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
/// <summary>Abgabe erfolgte wegen Zeitablauf, nicht durch das Kind.</summary>
public bool AutoSubmitted { get; set; }
/// <summary>Kommagetrennte Aufgaben-Ids in der Reihenfolge dieses Versuchs (stabil, ggf. gemischt).</summary>
public string ItemOrder { get; set; } = string.Empty;
public DateTimeOffset? ReviewedAt { get; set; }
public Guid? ReviewedBy { get; set; }
/// <summary>Erreichte Punkte, gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
public int? EarnedPoints { get; set; }
public int? TotalItemPoints { get; set; }
/// <summary>Schulnote 1–6, nur für Prüfungen und gültig ab <see cref="LearningAttemptStatus.Reviewed"/>.</summary>
public int? Grade { get; set; }
public string? ReviewNote { get; set; }
public List<LearningAnswer> Answers { get; set; } = [];
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Antwort des Kindes auf eine Aufgabe und deren Bewertung.</summary>
public sealed class LearningAnswer : AuditableEntity
{
public const int ValueMaxLength = 500;
public const int ReasonMaxLength = 500;
public Guid AttemptId { get; set; }
public Guid ItemId { get; set; }
/// <summary>Antwort des Kindes (Zahl/Text als Eingabe, bei Auswahl der Index der Option); <c>null</c> = nicht beantwortet.</summary>
public string? Value { get; set; }
public DateTimeOffset? AnsweredAt { get; set; }
public LearningVerdict Verdict { get; set; } = LearningVerdict.Open;
public LearningGrader Grader { get; set; } = LearningGrader.Auto;
/// <summary>Erreichte Punkte dieser Aufgabe (0 bis Aufgabenpunkte).</summary>
public int AwardedPoints { get; set; }
public string? Reason { get; set; }
public DateTimeOffset? GradedAt { get; set; }
}
@@ -0,0 +1,224 @@
using System.Globalization;
using System.Text;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Ergebnis der automatischen Bewertung einer Antwort.</summary>
public sealed record AutoGradeResult(LearningVerdict Verdict, int AwardedPoints, string? Reason = null);
/// <summary>
/// Liest Zahlen, wie Kinder sie tippen: Komma oder Punkt als Dezimalzeichen, Punkt/Komma/Leerzeichen als Tausendertrenner.
/// Läuft unabhängig von der Kultur des Geräts (immer <see cref="CultureInfo.InvariantCulture"/>), damit Api und WASM-Client gleich rechnen.
/// Brüche („1/2“) werden bewusst nicht erkannt.
/// </summary>
public static class LearningNumbers
{
private const int MaxLength = 28;
/// <summary>
/// Mögliche Werte der Eingabe. Meist genau einer; bei „1.234“ bzw. „1,234“ (genau drei Nachkommastellen, 1–3 Ziffern davor)
/// sind Dezimalzahl und Tausendertrennung möglich – dann kommen beide Werte zurück (Dezimalwert zuerst).
/// </summary>
public static IReadOnlyList<decimal> Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return [];
}
var builder = new StringBuilder(text.Length);
foreach (var c in text.Trim())
{
switch (c)
{
case ' ' or ' ' or ' ' or ' ':
continue; // Tausender-Leerzeichen
case '−' or '–':
builder.Append('-');
break;
default:
builder.Append(c);
break;
}
}
var s = builder.ToString();
if (s.Length == 0 || s.Length > MaxLength)
{
return [];
}
var negative = false;
if (s[0] is '-' or '+')
{
negative = s[0] == '-';
s = s[1..];
}
if (s.Length == 0 || !s.All(c => char.IsAsciiDigit(c) || c is '.' or ',') || !s.Any(char.IsAsciiDigit))
{
return [];
}
var candidates = Candidates(s);
return candidates
.Select(value => negative ? -value : value)
.ToList();
}
/// <summary>Erster (bevorzugter) Wert oder <c>null</c>, wenn keine Zahl erkannt wurde.</summary>
public static decimal? ParseFirst(string? text) => Parse(text) is { Count: > 0 } list ? list[0] : null;
private static List<decimal> Candidates(string s)
{
var dots = s.Count(c => c == '.');
var commas = s.Count(c => c == ',');
if (dots == 0 && commas == 0)
{
return Single(TryDecimal(s));
}
if (dots > 0 && commas > 0)
{
// Das letzte Trennzeichen ist das Komma der Dezimalstelle, das andere trennt Tausender.
var last = s.LastIndexOfAny(['.', ',']);
var decimalSeparator = s[last];
var thousandsSeparator = decimalSeparator == '.' ? ',' : '.';
if (s.Count(c => c == decimalSeparator) != 1)
{
return [];
}
var integerPart = s[..last];
var fraction = s[(last + 1)..];
return IsThousandsGrouped(integerPart, thousandsSeparator)
? Single(TryDecimal(integerPart.Replace(thousandsSeparator.ToString(), string.Empty) + "." + fraction))
: [];
}
var separator = dots > 0 ? '.' : ',';
var count = Math.Max(dots, commas);
if (count > 1)
{
// Mehrfach vorkommend: nur als Tausendertrenner sinnvoll („1.234.567“).
return IsThousandsGrouped(s, separator)
? Single(TryDecimal(s.Replace(separator.ToString(), string.Empty)))
: [];
}
var index = s.IndexOf(separator);
var before = s[..index];
var after = s[(index + 1)..];
var asDecimal = TryDecimal((before.Length == 0 ? "0" : before) + "." + after);
var ambiguous = after.Length == 3 && before.Length is >= 1 and <= 3 && before[0] != '0' && after.All(char.IsAsciiDigit);
var result = Single(asDecimal);
if (ambiguous && TryDecimal(before + after) is { } grouped)
{
result.Add(grouped);
}
return result;
}
/// <summary>1–3 Ziffern, danach Gruppen aus genau drei Ziffern, getrennt durch <paramref name="separator"/>.</summary>
private static bool IsThousandsGrouped(string text, char separator)
{
var groups = text.Split(separator);
return groups.Length >= 2
&& groups[0].Length is >= 1 and <= 3
&& groups.All(g => g.All(char.IsAsciiDigit))
&& groups.Skip(1).All(g => g.Length == 3);
}
private static List<decimal> Single(decimal? value) => value is { } v ? [v] : [];
private static decimal? TryDecimal(string text) =>
decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value) ? value : null;
}
/// <summary>Automatische Bewertung ohne KI (ADR-0021 Punkt 3): Zahlen und Auswahl endgültig, Freitext nur bei exakter Übereinstimmung.</summary>
public static class LearningGrading
{
/// <summary>Text vergleichbar machen: Unicode-Normalform, Leerraum zusammenfassen, Groß-/Kleinschreibung und Randzeichen ignorieren.</summary>
public static string NormalizeText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
var normalized = text.Normalize(NormalizationForm.FormC);
var builder = new StringBuilder(normalized.Length);
var pendingSpace = false;
foreach (var c in normalized)
{
if (char.IsWhiteSpace(c))
{
pendingSpace = builder.Length > 0;
continue;
}
if (pendingSpace)
{
builder.Append(' ');
pendingSpace = false;
}
builder.Append(c);
}
return builder.ToString().Trim().TrimEnd('.', '!', '?', ';').Trim().ToLowerInvariant();
}
/// <summary>Bewertet die Antwort einer Aufgabe. Eine leere Antwort ist immer falsch.</summary>
public static AutoGradeResult Grade(LearningItem item, string? answer)
{
if (string.IsNullOrWhiteSpace(answer))
{
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Antwort.");
}
return item.AnswerType switch
{
LearningAnswerType.Number => GradeNumber(item, answer),
LearningAnswerType.SingleChoice => GradeChoice(item, answer),
LearningAnswerType.Text => GradeText(item, answer),
_ => new AutoGradeResult(LearningVerdict.Open, 0), // Paper: Prüfung durch Eltern (Phase 10c)
};
}
private static AutoGradeResult GradeNumber(LearningItem item, string answer)
{
var given = LearningNumbers.Parse(answer);
if (given.Count == 0)
{
return new AutoGradeResult(LearningVerdict.Wrong, 0, "Keine Zahl erkannt.");
}
var expected = LearningNumbers.Parse(item.CorrectAnswer);
var tolerance = Math.Abs(item.NumberTolerance ?? 0m);
var correct = given.Any(g => expected.Any(e => Math.Abs(g - e) <= tolerance));
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
}
private static AutoGradeResult GradeChoice(LearningItem item, string answer)
{
var correct = int.TryParse(answer.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var chosen)
&& int.TryParse(item.CorrectAnswer?.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var expected)
&& chosen == expected
&& chosen < item.Options.Count;
return correct ? Correct(item) : new AutoGradeResult(LearningVerdict.Wrong, 0);
}
private static AutoGradeResult GradeText(LearningItem item, string answer)
{
var given = NormalizeText(answer);
var accepted = (item.CorrectAnswer ?? string.Empty).Split('|').Select(NormalizeText).Where(a => a.Length > 0);
return accepted.Contains(given, StringComparer.Ordinal)
? Correct(item)
: new AutoGradeResult(LearningVerdict.Open, 0); // Abweichungen entscheiden die Eltern (Schreibweise, Synonyme)
}
private static AutoGradeResult Correct(LearningItem item) => new(LearningVerdict.Correct, item.Points);
}
@@ -0,0 +1,394 @@
using System.Security.Cryptography;
using FamilyPlanner.Domain.Authorization;
namespace FamilyPlanner.Domain.Learning;
/// <summary>Der handelnde Benutzer mit seinen Lern-Rechten (Rollenmatrix, ADR-0008); gleich gebildet in Api und Client.</summary>
public sealed record LearningActor(Guid UserId, bool CanManage, AccessLevel Solve)
{
public static LearningActor From(Guid userId, IEnumerable<string> roles, PermissionOptions? options = null)
{
var list = roles.ToList();
return new LearningActor(
userId,
PermissionMatrix.For(Permission.LearningManage, list, options) == AccessLevel.Full,
PermissionMatrix.For(Permission.LearningSolve, list, options));
}
}
/// <summary>Voraussetzung für einen neuen Versuch.</summary>
public enum AttemptStart
{
Ok = 0,
/// <summary>Blatt ist (noch) nicht zugewiesen.</summary>
NotAssigned = 1,
/// <summary>Es läuft bereits ein Versuch – dieser wird fortgesetzt.</summary>
Running = 2,
/// <summary>Alle erlaubten Versuche sind verbraucht.</summary>
NoAttemptsLeft = 3,
}
/// <summary>Was das Kind zu einer Aufgabe sehen darf – Grundlage aller Kinder-DTOs (nie Lösungen vorab).</summary>
public sealed record LearningVisibility(bool ShowHint, bool ShowVerdict, bool ShowSolution);
/// <summary>Fachliche Regeln der Lernaufgaben, rein und ohne Abhängigkeit von EF/ASP.NET (ADR-0021).</summary>
public static class LearningRules
{
/// <summary>Feste Toleranz für Netzverzögerungen: Speicherungen bis so lange nach dem Ablauf werden noch akzeptiert.</summary>
public static readonly TimeSpan DeadlineTolerance = TimeSpan.FromSeconds(10);
// ---------- Rechte ----------
/// <summary>Lösen darf nur die Zielperson, und nur zugewiesene Blätter (Entwürfe sind für Kinder unsichtbar).</summary>
public static bool CanSolve(LearningActor actor, Guid targetUserId, LearningSetStatus status) =>
actor.Solve != AccessLevel.None && targetUserId == actor.UserId && status != LearningSetStatus.Draft;
// ---------- Zeitlimit ----------
/// <summary>Ablaufzeitpunkt eines Versuchs oder <c>null</c> ohne Zeitlimit.</summary>
public static DateTimeOffset? DeadlineFor(DateTimeOffset startedAt, int? timeLimitMinutes) =>
timeLimitMinutes is > 0 ? startedAt.AddMinutes(timeLimitMinutes.Value) : null;
/// <summary>Antworten dürfen noch gespeichert werden (Frist plus feste Toleranz).</summary>
public static bool AcceptsSaves(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is null || now <= deadline.Value + DeadlineTolerance;
/// <summary>Die Frist samt Toleranz ist vorbei: der Versuch muss automatisch abgegeben werden.</summary>
public static bool MustAutoSubmit(DateTimeOffset? deadline, DateTimeOffset now) => !AcceptsSaves(deadline, now);
/// <summary>Zeitstempel der Abgabe: nie nach der Frist.</summary>
public static DateTimeOffset SubmittedAtFor(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is { } d && now > d ? d : now;
/// <summary>Verbleibende Zeit (nie negativ) oder <c>null</c> ohne Zeitlimit.</summary>
public static TimeSpan? Remaining(DateTimeOffset? deadline, DateTimeOffset now) =>
deadline is { } d ? (d - now < TimeSpan.Zero ? TimeSpan.Zero : d - now) : null;
// ---------- Versuche und Status ----------
/// <summary>Status des Blatts aus den Versuchen (ab Zuweisung). Entwürfe bleiben Entwürfe.</summary>
public static LearningSetStatus DeriveStatus(LearningSetStatus current, IReadOnlyCollection<LearningAttemptStatus> attempts)
{
if (current == LearningSetStatus.Draft)
{
return LearningSetStatus.Draft;
}
if (attempts.Contains(LearningAttemptStatus.InProgress))
{
return LearningSetStatus.InProgress;
}
if (attempts.Contains(LearningAttemptStatus.Submitted))
{
return LearningSetStatus.Submitted;
}
return attempts.Contains(LearningAttemptStatus.Reviewed) ? LearningSetStatus.Reviewed : LearningSetStatus.Assigned;
}
/// <summary>Darf ein neuer Versuch gestartet werden? <paramref name="maxAttempts"/> = 0 bedeutet unbegrenzt.</summary>
public static AttemptStart CanStartAttempt(LearningSetStatus status, int maxAttempts, IReadOnlyCollection<LearningAttemptStatus> attempts)
{
if (status == LearningSetStatus.Draft)
{
return AttemptStart.NotAssigned;
}
if (attempts.Contains(LearningAttemptStatus.InProgress))
{
return AttemptStart.Running;
}
return maxAttempts > 0 && attempts.Count >= maxAttempts ? AttemptStart.NoAttemptsLeft : AttemptStart.Ok;
}
public static int AttemptsLeft(int maxAttempts, int used) => maxAttempts <= 0 ? int.MaxValue : Math.Max(0, maxAttempts - used);
/// <summary>Aufgaben dürfen nur geändert werden, solange es noch keinen Versuch gibt (sonst ändern sich Bewertungen nachträglich).</summary>
public static bool CanEditItems(int attemptCount) => attemptCount == 0;
/// <summary>Zuweisen: mindestens eine Aufgabe, jede mit gültiger Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
public static string? CheckAssignable(LearningSetStatus status, IReadOnlyCollection<LearningItem> items)
{
if (status != LearningSetStatus.Draft)
{
return "Das Blatt ist bereits zugewiesen.";
}
if (items.Count == 0)
{
return "Das Blatt braucht mindestens eine Aufgabe.";
}
var broken = items.FirstOrDefault(i => LearningItemRules.CheckSolution(i) is not null);
return broken is null ? null : $"Aufgabe {broken.SortOrder + 1}: {LearningItemRules.CheckSolution(broken)}";
}
// ---------- Sichtbarkeit ----------
/// <summary>
/// Übung: Hinweise und Sofort-Rückmeldung (nur richtig/falsch, nie die Lösung) während der Bearbeitung.
/// Prüfung: während der Bearbeitung nichts. Nach der Abgabe: Bewertung immer, Lösung nur wenn eingestellt, Hinweise nur in der Übung.
/// </summary>
public static LearningVisibility VisibilityFor(LearningMode mode, LearningAttemptStatus status, bool showSolutionsAfterSubmit)
{
var practice = mode == LearningMode.Practice;
return status == LearningAttemptStatus.InProgress
? new LearningVisibility(ShowHint: practice, ShowVerdict: practice, ShowSolution: false)
: new LearningVisibility(ShowHint: practice, ShowVerdict: true, ShowSolution: showSolutionsAfterSubmit);
}
// ---------- Reihenfolge ----------
/// <summary>
/// Aufgaben-Reihenfolge eines Versuchs. Gemischt wird deterministisch (SHA-256 aus Versuchs- und Aufgaben-Id), damit die
/// Reihenfolge je Versuch stabil ist, aber zwischen Versuchen wechselt. <c>string.GetHashCode</c> wäre je Prozess zufällig.
/// </summary>
public static List<Guid> OrderItems(IReadOnlyList<Guid> itemIdsInSortOrder, bool shuffle, Guid attemptId)
{
if (!shuffle)
{
return [.. itemIdsInSortOrder];
}
return itemIdsInSortOrder
.Select(id => (Id: id, Key: ShuffleKey(attemptId, id)))
.OrderBy(x => x.Key)
.ThenBy(x => x.Id)
.Select(x => x.Id)
.ToList();
}
private static ulong ShuffleKey(Guid attemptId, Guid itemId)
{
Span<byte> bytes = stackalloc byte[32];
attemptId.TryWriteBytes(bytes[..16]);
itemId.TryWriteBytes(bytes[16..]);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return BitConverter.ToUInt64(hash[..8]);
}
public static string SerializeOrder(IEnumerable<Guid> ids) => string.Join(',', ids);
public static List<Guid> ParseOrder(string? text) =>
string.IsNullOrWhiteSpace(text)
? []
: text.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(part => Guid.TryParse(part, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.ToList();
// ---------- Bewertung durch Eltern ----------
/// <summary>
/// Prüft eine Elternbewertung und liefert die zu speichernden Punkte. Richtig = volle Punkte, falsch = 0, teilweise = 1 bis
/// Aufgabenpunkte − 1 (ohne Angabe die Hälfte, abgerundet, mindestens 1). „Offen“ ist als Ergebnis nicht erlaubt.
/// </summary>
public static (int Points, string? Error) NormalizeParentVerdict(LearningVerdict verdict, int? awardedPoints, int itemPoints)
{
switch (verdict)
{
case LearningVerdict.Correct:
return (itemPoints, null);
case LearningVerdict.Wrong:
return (0, null);
case LearningVerdict.Partial:
if (itemPoints < 2)
{
return (0, "Teilpunkte gibt es erst ab 2 Aufgabenpunkten.");
}
var points = awardedPoints ?? Math.Max(1, itemPoints / 2);
return points is >= 1 && points <= itemPoints - 1
? (points, null)
: (0, $"Teilpunkte müssen zwischen 1 und {itemPoints - 1} liegen.");
default:
return (0, "Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
}
}
// ---------- Punkte ----------
/// <summary>Erreichte Punkte (offene Antworten zählen 0), Gesamtpunkte und Anzahl offener Antworten.</summary>
public static LearningScore Score(IEnumerable<ScoredAnswer> answers)
{
int earned = 0, total = 0, open = 0, correct = 0, count = 0;
foreach (var answer in answers)
{
count++;
total += answer.ItemPoints;
switch (answer.Verdict)
{
case LearningVerdict.Open:
open++;
break;
case LearningVerdict.Correct:
correct++;
earned += answer.ItemPoints;
break;
default:
earned += Math.Clamp(answer.AwardedPoints, 0, answer.ItemPoints);
break;
}
}
return new LearningScore(earned, total, open, correct, count);
}
/// <summary>
/// Punkte, die das Blatt insgesamt im Punktebuch ergeben soll: die <b>beste</b> bestätigte Wertung (Anteil erreichter Punkte)
/// mal <paramref name="setTotalPoints"/>, kaufmännisch auf ganze Punkte gerundet (halbe aufwärts). Ohne bestätigten Versuch 0.
/// </summary>
public static int TargetCredit(int setTotalPoints, IEnumerable<(int Earned, int Total)> confirmedAttempts)
{
if (setTotalPoints <= 0)
{
return 0;
}
(int Earned, int Total)? best = null;
foreach (var attempt in confirmedAttempts.Where(a => a.Total > 0))
{
// Brüche ohne Gleitkomma vergleichen: a/b > c/d <=> a*d > c*b
if (best is null || (long)attempt.Earned * best.Value.Total > (long)best.Value.Earned * attempt.Total)
{
best = attempt;
}
}
return best is { } b ? (int)((2L * b.Earned * setTotalPoints + b.Total) / (2L * b.Total)) : 0;
}
/// <summary>Zu buchende Differenz zum bereits gebuchten Stand (negativ = Rückbuchung).</summary>
public static int CreditDelta(int target, int alreadyBooked) => target - alreadyBooked;
}
/// <summary>Eine bewertete Antwort für die Punkteberechnung.</summary>
public sealed record ScoredAnswer(int ItemPoints, LearningVerdict Verdict, int AwardedPoints);
public sealed record LearningScore(int Earned, int Total, int OpenCount, int CorrectCount, int AnswerCount)
{
public double Percent => Total <= 0 ? 0 : Earned * 100.0 / Total;
}
/// <summary>Notenschlüssel: Mindestprozent für die Noten 1 bis 5; darunter gibt es die 6. Standard: Schulnoten 92/81/67/50/30.</summary>
public static class LearningGrades
{
public static IReadOnlyList<int> DefaultThresholds { get; } = [92, 81, 67, 50, 30];
/// <summary>Liest <c>92;81;67;50;30</c>; leer = Standard. Fünf absteigende Prozentwerte zwischen 1 und 100.</summary>
public static bool TryParse(string? text, out IReadOnlyList<int> thresholds, out string? error)
{
thresholds = DefaultThresholds;
error = null;
if (string.IsNullOrWhiteSpace(text))
{
return true;
}
var parts = text.Split([';', ','], StringSplitOptions.TrimEntries);
var values = new List<int>();
foreach (var part in parts)
{
if (!int.TryParse(part, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value))
{
error = "Der Notenschlüssel braucht fünf Prozentwerte, getrennt durch Semikolon (z. B. 92;81;67;50;30).";
return false;
}
values.Add(value);
}
if (values.Count != 5 || values.Any(v => v is < 1 or > 100))
{
error = "Der Notenschlüssel braucht fünf Prozentwerte zwischen 1 und 100.";
return false;
}
for (var i = 1; i < values.Count; i++)
{
if (values[i] >= values[i - 1])
{
error = "Die Prozentwerte des Notenschlüssels müssen von Note 1 bis 5 abnehmen.";
return false;
}
}
thresholds = values;
return true;
}
/// <summary>Normalisierte Schreibweise zum Speichern; <c>null</c> für den Standard.</summary>
public static string? Format(IReadOnlyList<int> thresholds) =>
thresholds.SequenceEqual(DefaultThresholds) ? null : string.Join(';', thresholds);
/// <summary>Note 1–6 aus erreichten und möglichen Punkten (exakter Ganzzahlvergleich, keine Rundungsfehler).</summary>
public static int GradeFor(int earned, int total, IReadOnlyList<int>? thresholds = null)
{
thresholds ??= DefaultThresholds;
if (total <= 0)
{
return 6;
}
for (var i = 0; i < thresholds.Count; i++)
{
if (earned * 100L >= (long)thresholds[i] * total)
{
return i + 1;
}
}
return 6;
}
public static string Label(int grade) => grade switch
{
1 => "sehr gut",
2 => "gut",
3 => "befriedigend",
4 => "ausreichend",
5 => "mangelhaft",
6 => "ungenügend",
_ => string.Empty,
};
}
/// <summary>Prüfung der Felder einer Aufgabe, die der Validator ohne Entität nicht kennt.</summary>
public static class LearningItemRules
{
/// <summary>Typ-spezifische Prüfung der Musterlösung. Liefert eine Fehlermeldung oder <c>null</c>.</summary>
public static string? CheckSolution(LearningItem item)
{
switch (item.AnswerType)
{
case LearningAnswerType.Number:
return LearningNumbers.Parse(item.CorrectAnswer).Count == 0 ? "Die Musterlösung muss eine Zahl sein." : null;
case LearningAnswerType.Text:
return string.IsNullOrWhiteSpace(item.CorrectAnswer) ? "Bitte eine Musterlösung angeben." : null;
case LearningAnswerType.SingleChoice:
if (item.Options.Count is < LearningItem.MinOptions or > LearningItem.MaxOptions)
{
return $"Eine Auswahlaufgabe braucht {LearningItem.MinOptions} bis {LearningItem.MaxOptions} Optionen.";
}
return int.TryParse(item.CorrectAnswer, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var index)
&& index < item.Options.Count
? null
: "Bitte die richtige Option wählen.";
default:
return "Papieraufgaben sind noch nicht verfügbar.";
}
}
/// <summary>Antworttypen, die in dieser Ausbaustufe angelegt werden dürfen (<see cref="LearningAnswerType.Paper"/> folgt in Phase 10c).</summary>
public static bool IsSupported(LearningAnswerType type) => type is LearningAnswerType.Number or LearningAnswerType.Text or LearningAnswerType.SingleChoice;
/// <summary>Vorschlag für die Punkte nach Schwierigkeit (leicht 1, mittel 2, schwer 3).</summary>
public static int DefaultPoints(int difficulty) => Math.Clamp(difficulty, LearningItem.MinDifficulty, LearningItem.MaxDifficulty);
}
@@ -228,5 +228,8 @@ public sealed class PointsLedgerEntry : AuditableEntity
public Guid? TaskId { get; set; }
/// <summary>Buchung zu einem Lernblatt (ADR-0021): Gutschrift, Verbesserung oder Rückbuchung.</summary>
public Guid? LearningSetId { get; set; }
public string Reason { get; set; } = string.Empty;
}
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Domain.Meals;
using FamilyPlanner.Domain.Push;
using FamilyPlanner.Domain.Settings;
@@ -70,6 +71,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<AiUsage> AiUsage => Set<AiUsage>();
public DbSet<LearningSet> LearningSets => Set<LearningSet>();
public DbSet<LearningItem> LearningItems => Set<LearningItem>();
public DbSet<LearningAttempt> LearningAttempts => Set<LearningAttempt>();
public DbSet<LearningAnswer> LearningAnswers => Set<LearningAnswer>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -0,0 +1,83 @@
using FamilyPlanner.Domain.Learning;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class LearningSetConfiguration : IEntityTypeConfiguration<LearningSet>
{
public void Configure(EntityTypeBuilder<LearningSet> builder)
{
builder.ToTable("LearningSets");
builder.Property(s => s.Title).HasMaxLength(LearningSet.TitleMaxLength).IsRequired();
builder.Property(s => s.Subject).HasMaxLength(LearningSet.SubjectMaxLength).IsRequired();
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.Mode).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.Source).HasConversion<string>().HasMaxLength(20);
builder.Property(s => s.GradeThresholds).HasMaxLength(LearningSet.GradeThresholdsMaxLength);
builder.Property(s => s.AiPrompt).HasMaxLength(LearningSet.AiPromptMaxLength);
builder.Property(s => s.AiProvider).HasMaxLength(LearningSet.AiProviderMaxLength);
builder.Property(s => s.AiModel).HasMaxLength(LearningSet.AiProviderMaxLength);
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasMany(s => s.Items).WithOne().HasForeignKey(i => i.SetId).OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.Attempts).WithOne().HasForeignKey(a => a.SetId).OnDelete(DeleteBehavior.Cascade);
// Kind-Ansicht „meine Blätter“ und Eltern-Übersicht.
builder.HasIndex(s => new { s.TargetUserId, s.Status });
builder.HasIndex(s => s.Status);
}
}
internal sealed class LearningItemConfiguration : IEntityTypeConfiguration<LearningItem>
{
public void Configure(EntityTypeBuilder<LearningItem> builder)
{
builder.ToTable("LearningItems");
builder.Property(i => i.Question).HasMaxLength(LearningItem.QuestionMaxLength).IsRequired();
builder.Property(i => i.AnswerType).HasConversion<string>().HasMaxLength(20);
builder.Property(i => i.CorrectAnswer).HasMaxLength(LearningItem.CorrectAnswerMaxLength);
builder.Property(i => i.NumberTolerance).HasPrecision(18, 6);
builder.PrimitiveCollection(i => i.Options).IsRequired();
builder.Property(i => i.Hint).HasMaxLength(LearningItem.HintMaxLength);
builder.Property(i => i.Explanation).HasMaxLength(LearningItem.ExplanationMaxLength);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.SetId, i.SortOrder });
}
}
internal sealed class LearningAttemptConfiguration : IEntityTypeConfiguration<LearningAttempt>
{
public void Configure(EntityTypeBuilder<LearningAttempt> builder)
{
builder.ToTable("LearningAttempts");
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.ItemOrder).HasMaxLength(LearningAttempt.ItemOrderMaxLength).IsRequired();
builder.Property(a => a.ReviewNote).HasMaxLength(LearningAttempt.NoteMaxLength);
builder.Property(a => a.RowVersion).IsRowVersion();
// Der Benutzer ist eine bloße Id (kein Fremdschlüssel, wie bei Aufgaben) – das vermeidet mehrfache Kaskadenpfade.
builder.HasMany(a => a.Answers).WithOne().HasForeignKey(x => x.AttemptId).OnDelete(DeleteBehavior.Cascade);
// Versuchsnummern sind je Blatt und Kind eindeutig; höchstens ein laufender Versuch – schützt vor Doppelklick/zwei Tabs
// (umgeht weder MaxAttempts noch setzt es die Prüfungsuhr zurück).
builder.HasIndex(a => new { a.SetId, a.UserId, a.Number }).IsUnique();
builder.HasIndex(a => new { a.SetId, a.UserId }).IsUnique().HasFilter("[Status] = 'InProgress'");
builder.HasIndex(a => new { a.Status, a.DeadlineAt });
}
}
internal sealed class LearningAnswerConfiguration : IEntityTypeConfiguration<LearningAnswer>
{
public void Configure(EntityTypeBuilder<LearningAnswer> builder)
{
builder.ToTable("LearningAnswers");
builder.Property(a => a.Value).HasMaxLength(LearningAnswer.ValueMaxLength);
builder.Property(a => a.Verdict).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.Grader).HasConversion<string>().HasMaxLength(20);
builder.Property(a => a.Reason).HasMaxLength(LearningAnswer.ReasonMaxLength);
// ItemId ist bewusst keine Beziehung (Aufgaben werden nach dem ersten Versuch nicht mehr verändert; kein zweiter Kaskadenpfad).
builder.HasIndex(a => new { a.AttemptId, a.ItemId }).IsUnique();
}
}
@@ -72,6 +72,7 @@ internal sealed class PointsLedgerEntryConfiguration : IEntityTypeConfiguration<
builder.Property(p => p.Reason).HasMaxLength(PointsLedgerEntry.ReasonMaxLength).IsRequired();
builder.HasIndex(p => p.UserId);
builder.HasIndex(p => p.TaskId);
builder.HasIndex(p => p.LearningSetId);
}
}
@@ -0,0 +1,241 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase10aLearning : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "LearningSetId",
table: "PointsLedger",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "LearningSets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Subject = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
GradeLevel = table.Column<int>(type: "int", nullable: false),
TargetUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DueDate = table.Column<DateOnly>(type: "date", nullable: true),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Mode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
MaxAttempts = table.Column<int>(type: "int", nullable: false),
TimeLimitMinutes = table.Column<int>(type: "int", nullable: true),
ShowSolutionsAfterSubmit = table.Column<bool>(type: "bit", nullable: false),
ShuffleItems = table.Column<bool>(type: "bit", nullable: false),
TotalPoints = table.Column<int>(type: "int", nullable: false),
GradeThresholds = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: true),
Source = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
AiPrompt = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
AiProvider = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AiModel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AssignedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ItemsRevision = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningSets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LearningAttempts",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Number = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeadlineAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LastActivityAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
AutoSubmitted = table.Column<bool>(type: "bit", nullable: false),
ItemOrder = table.Column<string>(type: "nvarchar(2400)", maxLength: 2400, nullable: false),
ReviewedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
ReviewedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
EarnedPoints = table.Column<int>(type: "int", nullable: true),
TotalItemPoints = table.Column<int>(type: "int", nullable: true),
Grade = table.Column<int>(type: "int", nullable: true),
ReviewNote = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningAttempts", x => x.Id);
table.ForeignKey(
name: "FK_LearningAttempts_LearningSets_SetId",
column: x => x.SetId,
principalTable: "LearningSets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LearningItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Question = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
AnswerType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CorrectAnswer = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
NumberTolerance = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
Options = table.Column<string>(type: "nvarchar(max)", nullable: false),
Hint = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
Explanation = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
Difficulty = table.Column<int>(type: "int", nullable: false),
Points = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningItems", x => x.Id);
table.ForeignKey(
name: "FK_LearningItems_LearningSets_SetId",
column: x => x.SetId,
principalTable: "LearningSets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LearningAnswers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AttemptId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Value = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AnsweredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
Verdict = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Grader = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
AwardedPoints = table.Column<int>(type: "int", nullable: false),
Reason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
GradedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LearningAnswers", x => x.Id);
table.ForeignKey(
name: "FK_LearningAnswers_LearningAttempts_AttemptId",
column: x => x.AttemptId,
principalTable: "LearningAttempts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PointsLedger_LearningSetId",
table: "PointsLedger",
column: "LearningSetId");
migrationBuilder.CreateIndex(
name: "IX_LearningAnswers_AttemptId_ItemId",
table: "LearningAnswers",
columns: new[] { "AttemptId", "ItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_SetId_UserId",
table: "LearningAttempts",
columns: new[] { "SetId", "UserId" },
unique: true,
filter: "[Status] = 'InProgress'");
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_SetId_UserId_Number",
table: "LearningAttempts",
columns: new[] { "SetId", "UserId", "Number" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LearningAttempts_Status_DeadlineAt",
table: "LearningAttempts",
columns: new[] { "Status", "DeadlineAt" });
migrationBuilder.CreateIndex(
name: "IX_LearningItems_IsDeleted",
table: "LearningItems",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_LearningItems_SetId_SortOrder",
table: "LearningItems",
columns: new[] { "SetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_LearningSets_IsDeleted",
table: "LearningSets",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_LearningSets_Status",
table: "LearningSets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_LearningSets_TargetUserId_Status",
table: "LearningSets",
columns: new[] { "TargetUserId", "Status" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LearningAnswers");
migrationBuilder.DropTable(
name: "LearningItems");
migrationBuilder.DropTable(
name: "LearningAttempts");
migrationBuilder.DropTable(
name: "LearningSets");
migrationBuilder.DropIndex(
name: "IX_PointsLedger_LearningSetId",
table: "PointsLedger");
migrationBuilder.DropColumn(
name: "LearningSetId",
table: "PointsLedger");
}
}
}
@@ -682,6 +682,355 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("ChatReactions", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("AnsweredAt")
.HasColumnType("datetimeoffset");
b.Property<Guid>("AttemptId")
.HasColumnType("uniqueidentifier");
b.Property<int>("AwardedPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("GradedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Grader")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<Guid>("ItemId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Reason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Value")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Verdict")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("AttemptId", "ItemId")
.IsUnique();
b.ToTable("LearningAnswers", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AutoSubmitted")
.HasColumnType("bit");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeadlineAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("EarnedPoints")
.HasColumnType("int");
b.Property<int?>("Grade")
.HasColumnType("int");
b.Property<string>("ItemOrder")
.IsRequired()
.HasMaxLength(2400)
.HasColumnType("nvarchar(2400)");
b.Property<DateTimeOffset>("LastActivityAt")
.HasColumnType("datetimeoffset");
b.Property<int>("Number")
.HasColumnType("int");
b.Property<string>("ReviewNote")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTimeOffset?>("ReviewedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("ReviewedBy")
.HasColumnType("uniqueidentifier");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid>("SetId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("StartedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("TotalItemPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SetId", "UserId")
.IsUnique()
.HasFilter("[Status] = 'InProgress'");
b.HasIndex("Status", "DeadlineAt");
b.HasIndex("SetId", "UserId", "Number")
.IsUnique();
b.ToTable("LearningAttempts", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AnswerType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("CorrectAnswer")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Difficulty")
.HasColumnType("int");
b.Property<string>("Explanation")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Hint")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("NumberTolerance")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.PrimitiveCollection<string>("Options")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Points")
.HasColumnType("int");
b.Property<string>("Question")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<Guid>("SetId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("SetId", "SortOrder");
b.ToTable("LearningItems", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AiModel")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("AiPrompt")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("AiProvider")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("AssignedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateOnly?>("DueDate")
.HasColumnType("date");
b.Property<int>("GradeLevel")
.HasColumnType("int");
b.Property<string>("GradeThresholds")
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemsRevision")
.HasColumnType("int");
b.Property<int>("MaxAttempts")
.HasColumnType("int");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<bool>("ShowSolutionsAfterSubmit")
.HasColumnType("bit");
b.Property<bool>("ShuffleItems")
.HasColumnType("bit");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("TargetUserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("TimeLimitMinutes")
.HasColumnType("int");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<int>("TotalPoints")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.HasIndex("Status");
b.HasIndex("TargetUserId", "Status");
b.ToTable("LearningSets", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Meals.Dish", b =>
{
b.Property<Guid>("Id")
@@ -1166,6 +1515,9 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("LearningSetId")
.HasColumnType("uniqueidentifier");
b.Property<int>("Points")
.HasColumnType("int");
@@ -1188,6 +1540,8 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("LearningSetId");
b.HasIndex("TaskId");
b.HasIndex("UserId");
@@ -1791,6 +2145,33 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAnswer", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningAttempt", null)
.WithMany("Answers")
.HasForeignKey("AttemptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
.WithMany("Attempts")
.HasForeignKey("SetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Learning.LearningSet", null)
.WithMany("Items")
.HasForeignKey("SetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FamilyPlanner.Domain.Shopping.ShoppingItem", b =>
{
b.HasOne("FamilyPlanner.Domain.Shopping.ShoppingList", null)
@@ -1883,6 +2264,18 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.Navigation("Permissions");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningAttempt", b =>
{
b.Navigation("Answers");
});
modelBuilder.Entity("FamilyPlanner.Domain.Learning.LearningSet", b =>
{
b.Navigation("Attempts");
b.Navigation("Items");
});
modelBuilder.Entity("FamilyPlanner.Domain.Tasks.TodoTask", b =>
{
b.Navigation("Assignments");
@@ -29,6 +29,22 @@ public sealed record DashboardFamilyTasksDto(int OpenCount, int OverdueCount, in
public sealed record DashboardChatDto(int UnreadCount);
/// <summary>Eintrag der Lern-Kachel: für Kinder ein offenes Blatt, für Eltern ein Versuch, der geprüft werden muss (<paramref name="AttemptId"/>).</summary>
public sealed record DashboardLearningItemDto(
Guid SetId,
Guid? AttemptId,
string Title,
string Subject,
DateOnly? DueDate,
bool IsOverdue,
string? ChildName);
/// <summary>
/// Lern-Kachel (ADR-0021). Kinder: offene Blätter. Eltern: <see cref="ToReviewCount"/> abgegebene Versuche zur Prüfung und
/// <see cref="OpenCount"/> noch offene Blätter der Kinder.
/// </summary>
public sealed record DashboardLearningDto(int OpenCount, int ToReviewCount, bool ForParents, IReadOnlyList<DashboardLearningItemDto> Items);
/// <summary>
/// Alle Kacheln der Startseite in einem Request. Eine Kachel ist <c>null</c>, wenn die Rolle sie nicht sehen darf
/// (z. B. Gäste ohne Chat, Kinder ohne Familienaufgaben). Die Berechtigungen werden serverseitig geprüft.
@@ -40,4 +56,5 @@ public sealed record DashboardDto(
DashboardShoppingDto? Shopping,
DashboardMyTasksDto? MyTasks,
DashboardFamilyTasksDto? FamilyTasks,
DashboardChatDto? Chat);
DashboardChatDto? Chat,
DashboardLearningDto? Learning = null);
@@ -0,0 +1,498 @@
using FamilyPlanner.Domain.Learning;
using FluentValidation;
namespace FamilyPlanner.Shared.Learning;
// =====================================================================================================================
// Eltern (Permission LearningManage): Blätter, Aufgaben mit Lösungen, Prüfung. Diese DTOs gehen NIE an Kinder.
// =====================================================================================================================
/// <summary>Aufgabe mit Musterlösung, Hinweis und Erklärung – nur für Eltern.</summary>
public sealed record LearningItemDto(
Guid Id,
int SortOrder,
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance,
IReadOnlyList<string> Options,
string? Hint,
string? Explanation,
int Difficulty,
int Points,
byte[] Version);
/// <summary>Blatt ohne Aufgaben (Listen, Übersicht) inkl. Kennzahlen; Prompt/Anbieter/Modell nur für Eltern.</summary>
public sealed record LearningSetDto(
Guid Id,
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate,
LearningSetStatus Status,
LearningMode Mode,
int MaxAttempts,
int? TimeLimitMinutes,
bool ShowSolutionsAfterSubmit,
bool ShuffleItems,
int TotalPoints,
string? GradeThresholds,
LearningSource Source,
string? AiPrompt,
string? AiProvider,
string? AiModel,
DateTimeOffset? AssignedAt,
int ItemCount,
int AttemptCount,
int PendingReviewCount,
int PointsCredited,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
byte[] Version);
public sealed record LearningAttemptSummaryDto(
Guid Id,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
int? EarnedPoints,
int? TotalItemPoints,
int? Grade);
public sealed record LearningSetDetailDto(
LearningSetDto Set,
IReadOnlyList<LearningItemDto> Items,
IReadOnlyList<LearningAttemptSummaryDto> Attempts);
/// <summary>Gemeinsame, bearbeitbare Felder einer Aufgabe.</summary>
public interface ILearningItemFields
{
string Question { get; }
LearningAnswerType AnswerType { get; }
string? CorrectAnswer { get; }
decimal? NumberTolerance { get; }
IReadOnlyList<string>? Options { get; }
string? Hint { get; }
string? Explanation { get; }
int Difficulty { get; }
int Points { get; }
}
/// <summary>Neue Aufgabe (auch als Teil von <see cref="CreateLearningSetRequest.Items"/>).</summary>
public sealed record LearningItemInput(
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance = null,
IReadOnlyList<string>? Options = null,
string? Hint = null,
string? Explanation = null,
int Difficulty = 1,
int Points = 1) : ILearningItemFields;
/// <summary>Aufgabe ändern. Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
public sealed record UpdateLearningItemRequest(
string Question,
LearningAnswerType AnswerType,
string? CorrectAnswer,
decimal? NumberTolerance = null,
IReadOnlyList<string>? Options = null,
string? Hint = null,
string? Explanation = null,
int Difficulty = 1,
int Points = 1,
byte[]? Version = null) : ILearningItemFields
{
public static UpdateLearningItemRequest From(LearningItemDto item) => new(
item.Question, item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Options, item.Hint, item.Explanation,
item.Difficulty, item.Points, item.Version);
}
public sealed record ReorderLearningItemsRequest(IReadOnlyList<Guid> ItemIds);
/// <summary>Gemeinsame Einstellungen eines Blatts.</summary>
public interface ILearningSetFields
{
string Title { get; }
string Subject { get; }
int GradeLevel { get; }
Guid TargetUserId { get; }
DateOnly? DueDate { get; }
LearningMode Mode { get; }
int MaxAttempts { get; }
int? TimeLimitMinutes { get; }
bool ShowSolutionsAfterSubmit { get; }
bool ShuffleItems { get; }
int TotalPoints { get; }
string? GradeThresholds { get; }
}
/// <summary>Neues Blatt (als Entwurf), optional gleich mit Aufgaben. Herkunft „KI“ setzt nur der Server (Phase 10b).</summary>
public sealed record CreateLearningSetRequest(
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate = null,
LearningMode Mode = LearningMode.Practice,
int MaxAttempts = 0,
int? TimeLimitMinutes = null,
bool ShowSolutionsAfterSubmit = true,
bool ShuffleItems = false,
int TotalPoints = 0,
string? GradeThresholds = null,
IReadOnlyList<LearningItemInput>? Items = null) : ILearningSetFields;
/// <summary>Blatt ändern (alle Einstellungen). Passt <see cref="Version"/> nicht, antwortet die Api mit 409 und dem aktuellen Stand.</summary>
public sealed record UpdateLearningSetRequest(
string Title,
string Subject,
int GradeLevel,
Guid TargetUserId,
DateOnly? DueDate = null,
LearningMode Mode = LearningMode.Practice,
int MaxAttempts = 0,
int? TimeLimitMinutes = null,
bool ShowSolutionsAfterSubmit = true,
bool ShuffleItems = false,
int TotalPoints = 0,
string? GradeThresholds = null,
byte[]? Version = null) : ILearningSetFields
{
public static UpdateLearningSetRequest From(LearningSetDto set) => new(
set.Title, set.Subject, set.GradeLevel, set.TargetUserId, set.DueDate, set.Mode, set.MaxAttempts, set.TimeLimitMinutes,
set.ShowSolutionsAfterSubmit, set.ShuffleItems, set.TotalPoints, set.GradeThresholds, set.Version);
}
/// <summary>Eintrag der Prüf-Warteschlange: abgegebener, noch nicht bestätigter Versuch.</summary>
public sealed record LearningReviewQueueItemDto(
Guid AttemptId,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
Guid ChildId,
string ChildName,
int Number,
DateTimeOffset SubmittedAt,
bool AutoSubmitted,
int OpenCount,
int EarnedPoints,
int TotalItemPoints);
/// <summary>Aufgabe eines Versuchs aus Elternsicht: Frage, Musterlösung, Kinderantwort und Bewertung.</summary>
public sealed record LearningReviewAnswerDto(
Guid ItemId,
int Position,
string Question,
LearningAnswerType AnswerType,
IReadOnlyList<string> Options,
string? CorrectAnswer,
string? Explanation,
int ItemPoints,
string? Answer,
LearningVerdict Verdict,
LearningGrader Grader,
int AwardedPoints,
string? Reason);
public sealed record LearningAttemptReviewDto(
Guid AttemptId,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
int SetTotalPoints,
Guid ChildId,
string ChildName,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
IReadOnlyList<LearningReviewAnswerDto> Answers,
int EarnedPoints,
int TotalItemPoints,
int OpenCount,
int? Grade,
int PointsCredited,
string? ReviewNote,
byte[] Version);
/// <summary>Elternbewertung einer Antwort: richtig, teilweise (mit Punkten) oder falsch; optional mit Begründung.</summary>
public sealed record ReviewAnswerRequest(LearningVerdict Verdict, int? AwardedPoints = null, string? Reason = null);
public sealed record ConfirmAttemptRequest(string? Note = null);
/// <summary>Ergebnis von Bestätigen/Erneut öffnen: der Versuch und die gebuchte Punktedifferenz (negativ = Rückbuchung).</summary>
public sealed record LearningReviewResultDto(LearningAttemptReviewDto Attempt, int PointsDelta, int PointsCredited);
// =====================================================================================================================
// Kind (Permission LearningSolve): eigene Blätter lösen. Eigene Typen OHNE Musterlösung/Prompt/Anbieter – Lösungen und
// Hinweise stehen nur in <see cref="LearningAttemptItemDto"/>, wenn die Regeln es erlauben (LearningRules.VisibilityFor).
// =====================================================================================================================
/// <summary>Ergebnis eines Versuchs aus Kindersicht (nach der Abgabe). <see cref="IsFinal"/>: von Eltern bestätigt.</summary>
public sealed record MyLearningResultDto(
Guid AttemptId,
int Number,
LearningAttemptStatus Status,
DateTimeOffset? SubmittedAt,
bool AutoSubmitted,
int EarnedPoints,
int TotalItemPoints,
int OpenCount,
int? Grade,
bool IsFinal);
/// <summary>Blatt in „Meine Blätter“ – ohne Aufgaben.</summary>
public sealed record MyLearningSetDto(
Guid Id,
string Title,
string Subject,
int GradeLevel,
DateOnly? DueDate,
LearningSetStatus Status,
LearningMode Mode,
int? TimeLimitMinutes,
int ItemCount,
int TotalPoints,
int MaxAttempts,
int AttemptsUsed,
bool CanStart,
Guid? RunningAttemptId,
int PointsCredited,
IReadOnlyList<MyLearningResultDto> Results);
/// <summary>Rückmeldung zu einer Antwort bzw. nach der Abgabe; Felder nur, wenn <c>LearningRules.VisibilityFor</c> sie erlaubt.</summary>
public sealed record LearningItemFeedbackDto(
LearningVerdict Verdict,
int AwardedPoints,
string? Reason,
string? CorrectAnswer,
string? Explanation);
/// <summary>Aufgabe in der Kinderansicht. <see cref="Hint"/> nur im Übungsmodus, <see cref="Feedback"/> nur wenn erlaubt.</summary>
public sealed record LearningAttemptItemDto(
Guid ItemId,
int Position,
string Question,
LearningAnswerType AnswerType,
IReadOnlyList<string> Options,
int Points,
int Difficulty,
string? Answer,
string? Hint,
LearningItemFeedbackDto? Feedback);
/// <summary>Ein Versuch aus Kindersicht. <see cref="ServerNow"/> dient dem Client zum Abgleich der Uhr (Countdown zur <see cref="DeadlineAt"/>).</summary>
public sealed record LearningAttemptDto(
Guid Id,
Guid SetId,
string SetTitle,
string Subject,
LearningMode Mode,
int Number,
LearningAttemptStatus Status,
DateTimeOffset StartedAt,
DateTimeOffset? DeadlineAt,
DateTimeOffset ServerNow,
bool AutoSubmitted,
IReadOnlyList<LearningAttemptItemDto> Items,
MyLearningResultDto? Result,
byte[] Version);
/// <summary>Antwort speichern (leer = löschen). Idempotent und zustandsbasiert: es zählt der zuletzt gespeicherte Wert.</summary>
public sealed record SaveAnswerRequest(string? Value);
/// <summary>Bestätigung einer gespeicherten Antwort; <see cref="Verdict"/> nur im Übungsmodus (Sofort-Rückmeldung, nie die Lösung).</summary>
public sealed record SaveAnswerResultDto(
Guid ItemId,
bool Answered,
LearningVerdict? Verdict,
DateTimeOffset? DeadlineAt,
DateTimeOffset ServerNow);
// =====================================================================================================================
// Live und Routen
// =====================================================================================================================
/// <summary>Namen der SignalR-Ereignisse: ohne Inhalte, nur an Zielperson und Eltern; der Client lädt über die geschützten Endpunkte nach.</summary>
public static class LearningEvents
{
public const string Changed = "LearningChanged";
}
public enum LearningChangeKind
{
SetChanged = 0,
Assigned = 1,
Submitted = 2,
Reviewed = 3,
Deleted = 4,
}
public sealed record LearningChangedDto(Guid SetId, LearningChangeKind Kind);
// =====================================================================================================================
// Validierung (Api verbindlich, Client für sofortiges Feedback)
// =====================================================================================================================
public static class LearningValidation
{
internal static void SetFields<T>(AbstractValidator<T> validator)
where T : ILearningSetFields
{
validator.RuleFor(r => r.Title)
.Must(t => !string.IsNullOrWhiteSpace(t)).WithMessage("Bitte einen Titel eingeben.")
.Must(t => t is null || t.Trim().Length <= LearningSet.TitleMaxLength)
.WithMessage($"Der Titel darf höchstens {LearningSet.TitleMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Subject).Must(LearningSubjects.IsValid).WithMessage("Bitte ein Fach wählen (Mathe oder Deutsch).");
validator.RuleFor(r => r.GradeLevel).InclusiveBetween(LearningSet.MinGradeLevel, LearningSet.MaxGradeLevel)
.WithMessage("Die Klassenstufe muss zwischen 1 und 6 liegen.");
validator.RuleFor(r => r.TargetUserId).NotEmpty().WithMessage("Bitte ein Kind auswählen.");
validator.RuleFor(r => r.Mode).IsInEnum().WithMessage("Unbekannter Modus.");
validator.RuleFor(r => r.MaxAttempts).InclusiveBetween(0, LearningSet.AttemptsLimit)
.WithMessage($"Die Zahl der Versuche muss zwischen 0 (unbegrenzt) und {LearningSet.AttemptsLimit} liegen.");
validator.RuleFor(r => r.TimeLimitMinutes).InclusiveBetween(1, LearningSet.MaxTimeLimitMinutes)
.When(r => r.TimeLimitMinutes is not null)
.WithMessage($"Das Zeitlimit muss zwischen 1 und {LearningSet.MaxTimeLimitMinutes} Minuten liegen.");
validator.RuleFor(r => r.TotalPoints).InclusiveBetween(0, LearningSet.MaxTotalPoints)
.WithMessage($"Die Gesamtpunkte müssen zwischen 0 und {LearningSet.MaxTotalPoints} liegen.");
validator.RuleFor(r => r.GradeThresholds)
.Must(text => LearningGrades.TryParse(text, out _, out _))
.WithMessage((request, text) => LearningGrades.TryParse(text, out _, out var error) ? string.Empty : error ?? "Ungültiger Notenschlüssel.");
}
internal static void ItemFields<T>(AbstractValidator<T> validator)
where T : ILearningItemFields
{
validator.RuleFor(r => r.Question)
.Must(q => !string.IsNullOrWhiteSpace(q)).WithMessage("Bitte eine Frage eingeben.")
.Must(q => q is null || q.Trim().Length <= LearningItem.QuestionMaxLength)
.WithMessage($"Die Frage darf höchstens {LearningItem.QuestionMaxLength} Zeichen haben.");
validator.RuleFor(r => r.AnswerType).Must(LearningItemRules.IsSupported)
.WithMessage("Dieser Antworttyp ist noch nicht verfügbar.");
validator.RuleFor(r => r.CorrectAnswer).MaximumLength(LearningItem.CorrectAnswerMaxLength)
.WithMessage($"Die Musterlösung darf höchstens {LearningItem.CorrectAnswerMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Hint).MaximumLength(LearningItem.HintMaxLength)
.WithMessage($"Der Hinweis darf höchstens {LearningItem.HintMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Explanation).MaximumLength(LearningItem.ExplanationMaxLength)
.WithMessage($"Die Erklärung darf höchstens {LearningItem.ExplanationMaxLength} Zeichen haben.");
validator.RuleFor(r => r.Difficulty).InclusiveBetween(LearningItem.MinDifficulty, LearningItem.MaxDifficulty)
.WithMessage("Die Schwierigkeit muss leicht, mittel oder schwer sein.");
validator.RuleFor(r => r.Points).InclusiveBetween(LearningItem.MinPoints, LearningItem.MaxPoints)
.WithMessage($"Die Punkte müssen zwischen {LearningItem.MinPoints} und {LearningItem.MaxPoints} liegen.");
validator.RuleFor(r => r.NumberTolerance).InclusiveBetween(0m, 1_000_000m).When(r => r.NumberTolerance is not null)
.WithMessage("Die Toleranz muss zwischen 0 und 1.000.000 liegen.");
validator.RuleFor(r => r.Options)
.Must(options => options is null || options.All(o => !string.IsNullOrWhiteSpace(o) && o.Trim().Length <= LearningItem.OptionMaxLength))
.WithMessage($"Optionen brauchen einen Text mit höchstens {LearningItem.OptionMaxLength} Zeichen.");
validator.RuleFor(r => r)
.Must(r => LearningItemRules.CheckSolution(ToItem(r)) is null)
.When(r => LearningItemRules.IsSupported(r.AnswerType))
.OverridePropertyName(nameof(ILearningItemFields.CorrectAnswer))
.WithMessage(r => LearningItemRules.CheckSolution(ToItem(r)) ?? string.Empty);
}
/// <summary>Entität aus den Eingabefeldern (bereinigt) – Grundlage der Prüfung und des Speicherns.</summary>
public static LearningItem ToItem(ILearningItemFields fields) => new()
{
Question = fields.Question?.Trim() ?? string.Empty,
AnswerType = fields.AnswerType,
CorrectAnswer = string.IsNullOrWhiteSpace(fields.CorrectAnswer) ? null : fields.CorrectAnswer.Trim(),
NumberTolerance = fields.AnswerType == LearningAnswerType.Number ? fields.NumberTolerance : null,
Options = fields.AnswerType == LearningAnswerType.SingleChoice
? (fields.Options ?? []).Select(o => o?.Trim() ?? string.Empty).ToList()
: [],
Hint = string.IsNullOrWhiteSpace(fields.Hint) ? null : fields.Hint.Trim(),
Explanation = string.IsNullOrWhiteSpace(fields.Explanation) ? null : fields.Explanation.Trim(),
Difficulty = fields.Difficulty,
Points = fields.Points,
};
}
public sealed class CreateLearningSetRequestValidator : AbstractValidator<CreateLearningSetRequest>
{
public CreateLearningSetRequestValidator()
{
LearningValidation.SetFields(this);
RuleFor(r => r.Items)
.Must(items => items is null || items.Count <= LearningSet.MaxItems)
.WithMessage($"Ein Blatt kann höchstens {LearningSet.MaxItems} Aufgaben haben.");
RuleForEach(r => r.Items).SetValidator(new LearningItemInputValidator());
}
}
public sealed class UpdateLearningSetRequestValidator : AbstractValidator<UpdateLearningSetRequest>
{
public UpdateLearningSetRequestValidator() => LearningValidation.SetFields(this);
}
public sealed class LearningItemInputValidator : AbstractValidator<LearningItemInput>
{
public LearningItemInputValidator() => LearningValidation.ItemFields(this);
}
public sealed class UpdateLearningItemRequestValidator : AbstractValidator<UpdateLearningItemRequest>
{
public UpdateLearningItemRequestValidator() => LearningValidation.ItemFields(this);
}
public sealed class ReorderLearningItemsRequestValidator : AbstractValidator<ReorderLearningItemsRequest>
{
public ReorderLearningItemsRequestValidator() =>
RuleFor(r => r.ItemIds)
.NotNull().WithMessage("Die Reihenfolge fehlt.")
.Must(ids => ids is null || (ids.Count <= LearningSet.MaxItems && ids.Distinct().Count() == ids.Count && ids.All(id => id != Guid.Empty)))
.WithMessage("Ungültige Reihenfolge.");
}
public sealed class SaveAnswerRequestValidator : AbstractValidator<SaveAnswerRequest>
{
public SaveAnswerRequestValidator() =>
RuleFor(r => r.Value).MaximumLength(LearningAnswer.ValueMaxLength)
.WithMessage($"Die Antwort darf höchstens {LearningAnswer.ValueMaxLength} Zeichen haben.");
}
public sealed class ReviewAnswerRequestValidator : AbstractValidator<ReviewAnswerRequest>
{
public ReviewAnswerRequestValidator()
{
RuleFor(r => r.Verdict).Must(v => v is LearningVerdict.Correct or LearningVerdict.Partial or LearningVerdict.Wrong)
.WithMessage("Bitte „richtig“, „teilweise“ oder „falsch“ wählen.");
RuleFor(r => r.AwardedPoints).InclusiveBetween(1, LearningItem.MaxPoints).When(r => r.AwardedPoints is not null)
.WithMessage("Ungültige Teilpunkte.");
RuleFor(r => r.Reason).MaximumLength(LearningAnswer.ReasonMaxLength)
.WithMessage($"Die Begründung darf höchstens {LearningAnswer.ReasonMaxLength} Zeichen haben.");
}
}
public sealed class ConfirmAttemptRequestValidator : AbstractValidator<ConfirmAttemptRequest>
{
public ConfirmAttemptRequestValidator() =>
RuleFor(r => r.Note).MaximumLength(LearningAttempt.NoteMaxLength)
.WithMessage($"Der Kommentar darf höchstens {LearningAttempt.NoteMaxLength} Zeichen haben.");
}
@@ -0,0 +1,76 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@* Ergebnis eines abgegebenen Versuchs aus Kindersicht. Lösungen, Erklärungen und Bewertungen erscheinen nur, wenn der Server sie mitschickt (LearningRules.VisibilityFor). *@
<section class="fp-learning-result-view" aria-label="Ergebnis">
@if (Attempt.Result is { } result)
{
<MudPaper Outlined="true" Class="pa-4 mb-3 fp-learning-summary">
<MudText Typo="Typo.h5" HtmlTag="h2">
@(Attempt.AutoSubmitted ? "Die Zeit ist abgelaufen – abgegeben" : "Abgegeben – gut gemacht!")
</MudText>
<MudText Typo="Typo.h4" Class="mt-2 fp-learning-score">@LearningFormat.Points(result.EarnedPoints) von @result.TotalItemPoints</MudText>
@if (result.Grade is { } grade)
{
<MudText Typo="Typo.h5" Color="Color.Primary" Class="fp-learning-grade">@LearningFormat.Grade(grade)</MudText>
}
@if (result.IsFinal)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.Verified" Class="fp-learning-final">Von Mama oder Papa bestätigt</MudChip>
}
else
{
<MudChip T="string" Color="Color.Warning" Icon="@Icons.Material.Filled.HourglassTop" Class="fp-learning-pending">
@(result.OpenCount > 0
? $"{(result.OpenCount == 1 ? "1 Antwort wird" : $"{result.OpenCount} Antworten werden")} noch von Mama oder Papa angeschaut"
: "Mama oder Papa bestätigen das Ergebnis noch")
</MudChip>
}
</MudPaper>
}
<ol class="fp-learning-answers">
@foreach (var item in Attempt.Items)
{
<li class="fp-learning-answer" data-item-id="@item.ItemId">
<div class="d-flex align-center gap-2 flex-wrap">
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Aufgabe @(item.Position + 1)</MudText>
<MudSpacer />
@if (item.Feedback is { } feedback)
{
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.VerdictColor(feedback.Verdict)" Icon="@LearningFormat.VerdictIcon(feedback.Verdict)"
Class="@($"fp-learning-verdict verdict-{feedback.Verdict.ToString().ToLowerInvariant()}")">
@LearningFormat.Verdict(feedback.Verdict)@(feedback.Verdict is LearningVerdict.Partial ? $" ({feedback.AwardedPoints} von {item.Points})" : string.Empty)
</MudChip>
}
</div>
<p class="fp-learning-question">@item.Question</p>
<p class="fp-learning-given">Deine Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
@if (item.Feedback?.CorrectAnswer is { } correct)
{
<p class="fp-learning-correct">Richtige Lösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, correct)</strong></p>
}
@if (!string.IsNullOrWhiteSpace(item.Feedback?.Explanation))
{
<p class="fp-learning-explanation">@item.Feedback!.Explanation</p>
}
@if (!string.IsNullOrWhiteSpace(item.Feedback?.Reason))
{
<p class="fp-learning-reason">Kommentar: @item.Feedback!.Reason</p>
}
</li>
}
</ol>
<MudButton Href="lernen" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" StartIcon="@Icons.Material.Filled.ArrowBack" Class="fp-learning-back">
Zu meinen Blättern
</MudButton>
</section>
@code {
[Parameter, EditorRequired]
public LearningAttemptDto Attempt { get; set; } = default!;
}
@@ -0,0 +1,316 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject ISnackbar Snackbar
@* Eltern prüfen einen abgegebenen Versuch: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen oder erneut öffnen. *@
<section class="fp-learning-review" aria-label="Versuch prüfen">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="lernen" aria-label="Zurück zur Übersicht" Class="fp-learning-editor-back" />
<MudText Typo="Typo.h4" HtmlTag="h1">Versuch prüfen</MudText>
</div>
@if (_review is null)
{
@if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">@(_notice ?? "Diesen Versuch gibt es nicht (mehr).")</MudAlert>
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else
{
<LoadingIndicator />
}
}
else
{
var review = _review;
var confirmed = review.Status == LearningAttemptStatus.Reviewed;
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-review-summary">
<MudText Typo="Typo.h6" HtmlTag="h2">@review.ChildName – @review.SetTitle</MudText>
<MudText Typo="Typo.body2">
@review.Subject · @LearningFormat.Mode(review.Mode) · Versuch @review.Number
@if (review.SubmittedAt is { } at)
{
<span> · abgegeben @LearningFormat.Local(at)</span>
}
</MudText>
@if (review.AutoSubmitted)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2 fp-review-auto">Die Zeit ist abgelaufen – der Versuch wurde automatisch abgegeben. Nur rechtzeitig gespeicherte Antworten zählen.</MudAlert>
}
<div class="d-flex flex-wrap align-center gap-2 mt-2">
<MudChip T="string" Color="Color.Primary" Class="fp-review-score">@LearningFormat.Points(review.EarnedPoints) von @review.TotalItemPoints</MudChip>
@if (review.OpenCount > 0)
{
<MudChip T="string" Color="Color.Info" Class="fp-review-open">@(review.OpenCount == 1 ? "1 Antwort offen" : $"{review.OpenCount} Antworten offen")</MudChip>
}
@if (review.Grade is { } grade)
{
<MudChip T="string" Color="Color.Secondary" Class="fp-review-grade">@(confirmed ? string.Empty : "Vorschau: ")@LearningFormat.Grade(grade)</MudChip>
}
@if (confirmed)
{
<MudChip T="string" Color="Color.Success" Icon="@Icons.Material.Filled.Verified" Class="fp-review-confirmed">Bestätigt</MudChip>
}
</div>
</MudPaper>
<ol class="fp-review-answers">
@foreach (var answer in review.Answers)
{
var item = answer;
<li class="fp-review-answer" data-item-id="@item.ItemId">
<div class="d-flex align-center flex-wrap gap-2">
<MudText Typo="Typo.subtitle2" HtmlTag="h3">Aufgabe @(item.Position + 1)</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Points(item.ItemPoints)</MudChip>
<MudSpacer />
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.VerdictColor(item.Verdict)" Icon="@LearningFormat.VerdictIcon(item.Verdict)"
Class="@($"fp-review-verdict verdict-{item.Verdict.ToString().ToLowerInvariant()}")">
@LearningFormat.Verdict(item.Verdict)@(item.Verdict == LearningVerdict.Partial ? $" ({item.AwardedPoints} von {item.ItemPoints})" : string.Empty)
</MudChip>
</div>
<p class="fp-learning-question">@item.Question</p>
<p class="fp-review-given">Antwort: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.Answer)</strong></p>
<p class="fp-review-correct">Musterlösung: <strong>@LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)</strong></p>
@if (!string.IsNullOrWhiteSpace(item.Explanation))
{
<p class="fp-learning-explanation">@item.Explanation</p>
}
<p class="fp-review-grader">@LearningFormat.Grader(item.Grader)@(string.IsNullOrWhiteSpace(item.Reason) ? string.Empty : $": {item.Reason}")</p>
@if (!confirmed)
{
<div class="d-flex flex-wrap align-center gap-2 fp-review-actions" role="group" aria-label="@($"Bewertung Aufgabe {item.Position + 1}")">
<MudButton Variant="@(item.Verdict == LearningVerdict.Correct ? Variant.Filled : Variant.Outlined)" Color="Color.Success" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Correct)" Class="fp-review-correct-button" aria-pressed="@(item.Verdict == LearningVerdict.Correct ? "true" : "false")">Richtig</MudButton>
@if (item.ItemPoints >= 2)
{
<MudButton Variant="@(item.Verdict == LearningVerdict.Partial ? Variant.Filled : Variant.Outlined)" Color="Color.Warning" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Partial)" Class="fp-review-partial-button" aria-pressed="@(item.Verdict == LearningVerdict.Partial ? "true" : "false")">Teilweise</MudButton>
}
<MudButton Variant="@(item.Verdict == LearningVerdict.Wrong ? Variant.Filled : Variant.Outlined)" Color="Color.Error" Disabled="_busy"
OnClick="() => ReviewAsync(item, LearningVerdict.Wrong)" Class="fp-review-wrong-button" aria-pressed="@(item.Verdict == LearningVerdict.Wrong ? "true" : "false")">Falsch</MudButton>
</div>
<div class="d-flex flex-wrap gap-2 mt-1">
@if (item.ItemPoints >= 2)
{
<MudNumericField T="int" Value="@PartialPoints(item)" ValueChanged="v => SetPartialPoints(item, v)" Label="Teilpunkte" Min="1" Max="item.ItemPoints - 1"
Variant="Variant.Outlined" Margin="Margin.Dense" Class="fp-review-points" />
}
<MudTextField T="string" Value="@ReasonOf(item)" ValueChanged="v => SetReason(item, v)" Label="Begründung (optional)" Variant="Variant.Outlined" Margin="Margin.Dense"
Class="flex-grow-1 fp-review-reason" MaxLength="LearningAnswer.ReasonMaxLength" />
</div>
}
</li>
}
</ol>
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-2 fp-dialog-error">@_error</MudAlert>
}
@if (!confirmed)
{
<MudTextField T="string" @bind-Value="_note" Label="Kommentar an das Kind (optional)" Variant="Variant.Outlined" Lines="2" MaxLength="LearningAttempt.NoteMaxLength" Class="mb-2 fp-review-note" />
@if (review.OpenCount > 0)
{
<MudText Typo="Typo.body2" Class="mb-2 fp-review-hint">Bitte zuerst alle offenen Antworten bewerten.</MudText>
}
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Large" StartIcon="@Icons.Material.Filled.Verified" OnClick="ConfirmAsync"
Disabled="@(_busy || review.OpenCount > 0)" Class="fp-review-confirm">Ergebnis bestätigen</MudButton>
}
else
{
<MudAlert Severity="Severity.Success" Class="mb-2 fp-review-credited">
Bestätigt@(review.SetTotalPoints > 0 ? $" – insgesamt {LearningFormat.Points(review.PointsCredited)} für dieses Blatt gutgeschrieben" : string.Empty).
</MudAlert>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.LockOpen" OnClick="ReopenAsync" Disabled="_busy" Class="fp-review-reopen">Erneut öffnen (Punkte werden neu berechnet)</MudButton>
}
}
</section>
@code {
private readonly Dictionary<Guid, int> _partial = [];
private readonly Dictionary<Guid, string?> _reasons = [];
private LearningAttemptReviewDto? _review;
private Guid _loadedId;
private string? _note;
private bool _busy;
private bool _missing;
private string? _error;
private string? _notice;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
protected override async Task OnParametersSetAsync()
{
if (AttemptId == _loadedId)
{
return;
}
_loadedId = AttemptId;
Live.LearningChanged -= OnLearningChanged;
Live.LearningChanged += OnLearningChanged;
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => _busy ? Task.CompletedTask : ReloadQuietAsync();
private async Task ReloadQuietAsync()
{
var result = await Api.GetReviewAttemptAsync(AttemptId);
if (result.Succeeded && result.Value is { } review)
{
_review = review;
}
await InvokeAsync(StateHasChanged);
}
private async Task LoadAsync()
{
_error = null;
_missing = false;
var result = await Api.GetReviewAttemptAsync(AttemptId);
if (result.Succeeded && result.Value is { } review)
{
_review = review;
_note = review.ReviewNote;
}
else if (result.Problem?.Status == 404)
{
_missing = true;
}
else if (result.Problem?.Status == 409)
{
_missing = true;
_notice = result.Problem.Detail ?? "Der Versuch läuft noch.";
}
else
{
_error = result.Problem?.Message ?? "Der Versuch konnte nicht geladen werden.";
}
}
private int PartialPoints(LearningReviewAnswerDto item) =>
_partial.TryGetValue(item.ItemId, out var points) ? points : item.Verdict == LearningVerdict.Partial ? item.AwardedPoints : Math.Max(1, item.ItemPoints / 2);
private void SetPartialPoints(LearningReviewAnswerDto item, int value) => _partial[item.ItemId] = value;
private string? ReasonOf(LearningReviewAnswerDto item) =>
_reasons.TryGetValue(item.ItemId, out var reason) ? reason : item.Reason;
private void SetReason(LearningReviewAnswerDto item, string? value) => _reasons[item.ItemId] = value;
private async Task ReviewAsync(LearningReviewAnswerDto item, LearningVerdict verdict)
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var points = verdict == LearningVerdict.Partial ? PartialPoints(item) : (int?)null;
var result = await Api.ReviewAnswerAsync(AttemptId, item.ItemId, new ReviewAnswerRequest(verdict, points, ReasonOf(item)));
if (result.Succeeded && result.Value is { } review)
{
_review = review;
_partial.Remove(item.ItemId);
_reasons.Remove(item.ItemId);
}
else
{
_error = result.Problem?.Message ?? "Die Bewertung konnte nicht gespeichert werden.";
if (result.Problem?.Status == 409)
{
await ReloadQuietAsync();
}
}
}
finally
{
_busy = false;
}
}
private async Task ConfirmAsync()
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var result = await Api.ConfirmAsync(AttemptId, new ConfirmAttemptRequest(string.IsNullOrWhiteSpace(_note) ? null : _note.Trim()));
if (result.Succeeded && result.Value is { } confirmed)
{
_review = confirmed.Attempt;
Snackbar.Add(confirmed.PointsDelta > 0 ? $"Bestätigt – {LearningFormat.Points(confirmed.PointsDelta)} gutgeschrieben." : "Bestätigt.", Severity.Success);
}
else
{
_error = result.Problem?.Message ?? "Das Ergebnis konnte nicht bestätigt werden.";
}
}
finally
{
_busy = false;
}
}
private async Task ReopenAsync()
{
if (_busy)
{
return;
}
_busy = true;
_error = null;
try
{
var result = await Api.ReopenAsync(AttemptId);
if (result.Succeeded && result.Value is { } reopened)
{
_review = reopened.Attempt;
Snackbar.Add(reopened.PointsDelta < 0 ? $"Wieder geöffnet – {LearningFormat.Points(-reopened.PointsDelta)} zurückgebucht." : "Wieder geöffnet.", Severity.Info);
}
else
{
_error = result.Problem?.Message ?? "Der Versuch konnte nicht wieder geöffnet werden.";
}
}
finally
{
_busy = false;
}
}
public void Dispose() => Live.LearningChanged -= OnLearningChanged;
}
@@ -0,0 +1,496 @@
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IAsyncDisposable
@inject ILearningApi Api
@inject TimeProvider TimeProvider
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Lösen eines Blatts (Übung und Prüfung). Die Uhr der Prüfung richtet sich nach der Frist des Servers (Abgleich über ServerNow), nicht nach dem Gerät. *@
<section class="fp-learning-runner" aria-label="Blatt lösen">
@if (_attempt is null)
{
@if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">Dieses Blatt gibt es nicht oder du hast keinen Zugriff.</MudAlert>
<MudButton Href="lernen" Variant="Variant.Text">Zu meinen Blättern</MudButton>
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else
{
<LoadingIndicator />
}
}
else if (_attempt.Status != LearningAttemptStatus.InProgress)
{
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">@_attempt.SetTitle</MudText>
<AttemptResult Attempt="_attempt" />
}
else
{
var items = _attempt.Items;
var item = items[Math.Clamp(_index, 0, items.Count - 1)];
var practice = _attempt.Mode == LearningMode.Practice;
var answered = items.Count(i => Normalize(Value(i.ItemId)) is not null);
<div class="fp-learning-run-header">
<div class="d-flex align-center flex-wrap gap-2">
<MudText Typo="Typo.h5" HtmlTag="h1" Class="fp-learning-run-title">@_attempt.SetTitle</MudText>
<MudChip T="string" Size="Size.Small" Color="@(practice ? Color.Default : Color.Warning)" Variant="Variant.Outlined" Class="fp-learning-mode">
@LearningFormat.Mode(_attempt.Mode)
</MudChip>
<MudSpacer />
@if (Remaining is { } remaining)
{
<span class="@($"fp-learning-timer {(remaining <= TimeSpan.FromMinutes(1) ? "is-urgent" : string.Empty)}")" role="timer" aria-label="Verbleibende Zeit">
<MudIcon Icon="@Icons.Material.Filled.Timer" Size="Size.Small" />
<span class="fp-learning-timer-text">@LearningFormat.Countdown(remaining)</span>
</span>
}
</div>
<div class="fp-learning-progress mt-2">
<MudProgressLinear Value="answered" Max="items.Count" Color="Color.Success" Rounded="true" Size="Size.Medium"
aria-label="Fortschritt" aria-valuemin="0" aria-valuemax="@items.Count" aria-valuenow="@answered" />
<span class="fp-learning-progress-text">@answered von @items.Count beantwortet</span>
</div>
<div class="mud-visually-hidden fp-learning-announce" role="status" aria-live="polite">@_announcement</div>
</div>
<nav aria-label="Aufgaben">
<ol class="fp-learning-dots">
@for (var i = 0; i < items.Count; i++)
{
var index = i;
var isAnswered = Normalize(Value(items[i].ItemId)) is not null;
<li>
<button type="button" class="@($"fp-learning-dot {(index == _index ? "is-current" : string.Empty)} {(isAnswered ? "is-answered" : string.Empty)}")"
aria-label="@($"Aufgabe {index + 1}{(isAnswered ? ", beantwortet" : ", offen")}")"
aria-current="@(index == _index ? "step" : null)" @onclick="() => GoToAsync(index)">@(index + 1)</button>
</li>
}
</ol>
</nav>
<MudPaper Outlined="true" Class="pa-4 my-3 fp-learning-question-card" data-item-id="@item.ItemId">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.overline" HtmlTag="h2">Aufgabe @(_index + 1) von @items.Count</MudText>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Points(item.Points)</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.Difficulty(item.Difficulty)</MudChip>
</div>
<p class="fp-learning-question" id="@QuestionId">@item.Question</p>
@switch (item.AnswerType)
{
case LearningAnswerType.SingleChoice:
<div role="radiogroup" aria-labelledby="@QuestionId" class="fp-learning-choices">
@for (var o = 0; o < item.Options.Count; o++)
{
var option = o;
var selected = Value(item.ItemId) == option.ToString();
<button type="button" role="radio" aria-checked="@(selected ? "true" : "false")" disabled="@_submitting"
class="@($"fp-learning-choice {(selected ? "is-selected" : string.Empty)}")" @onclick="() => SelectAsync(item, option)">
<span class="fp-learning-choice-mark" aria-hidden="true">@((char)('A' + option))</span>
<span>@item.Options[option]</span>
</button>
}
</div>
break;
case LearningAnswerType.Number:
<MudTextField T="string" Value="@Value(item.ItemId)" ValueChanged="v => TextChangedAsync(item, v)" Label="Deine Antwort (Zahl)"
Variant="Variant.Outlined" InputMode="@(InputMode.@decimal)" Disabled="_submitting" Class="fp-learning-input fp-learning-number"
InputId="@InputId" Clearable="false" />
break;
default:
<MudTextField T="string" Value="@Value(item.ItemId)" ValueChanged="v => TextChangedAsync(item, v)" Label="Deine Antwort"
Variant="Variant.Outlined" Lines="3" Disabled="_submitting" Class="fp-learning-input fp-learning-text" InputId="@InputId" />
break;
}
<div class="fp-learning-save-state" role="status" aria-live="polite">
@switch (_saveState)
{
case SaveState.Saving:
<span class="fp-learning-saving">Speichert …</span>
break;
case SaveState.Saved:
<span class="fp-learning-saved">Gespeichert ✓</span>
break;
case SaveState.Failed:
<span class="fp-learning-save-failed">Nicht gespeichert – <button type="button" class="fp-link-button" @onclick="RetrySaveAsync">erneut versuchen</button></span>
break;
}
</div>
@if (practice && _verdicts.TryGetValue(item.ItemId, out var verdict) && verdict is { } v)
{
<MudAlert Severity="@(v == LearningVerdict.Correct ? Severity.Success : v == LearningVerdict.Wrong ? Severity.Warning : Severity.Info)" Dense="true"
Class="mt-2 fp-learning-feedback">
@(v switch
{
LearningVerdict.Correct => "Richtig! 🎉",
LearningVerdict.Wrong => "Nicht ganz – versuch es noch einmal.",
_ => "Das schauen sich Mama oder Papa an.",
})
</MudAlert>
}
@if (item.Hint is { } hint)
{
@if (_hints.Contains(item.ItemId))
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2 fp-learning-hint" Icon="@Icons.Material.Filled.Lightbulb">@hint</MudAlert>
}
else
{
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Lightbulb" OnClick="() => _hints.Add(item.ItemId)" Class="mt-1 fp-learning-hint-button">
Hinweis anzeigen
</MudButton>
}
}
</MudPaper>
<div class="d-flex flex-wrap gap-2 align-center fp-learning-nav">
<MudButton Variant="Variant.Outlined" Size="Size.Large" StartIcon="@Icons.Material.Filled.ArrowBack" Disabled="@(_index == 0 || _submitting)"
OnClick="() => GoToAsync(_index - 1)" Class="fp-learning-prev">Zurück</MudButton>
@if (_index < items.Count - 1)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" EndIcon="@Icons.Material.Filled.ArrowForward" Disabled="_submitting"
OnClick="() => GoToAsync(_index + 1)" Class="fp-learning-next">Weiter</MudButton>
}
<MudSpacer />
<MudButton Variant="@(_index == items.Count - 1 ? Variant.Filled : Variant.Outlined)" Color="Color.Success" Size="Size.Large"
StartIcon="@Icons.Material.Filled.Send" Disabled="_submitting" OnClick="RequestSubmitAsync" Class="fp-learning-submit">Abgeben</MudButton>
</div>
}
</section>
@code {
private enum SaveState
{
Idle,
Saving,
Saved,
Failed,
}
private static readonly TimeSpan[] Announcements = [TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10)];
private readonly SemaphoreSlim _saveLock = new(1, 1);
private readonly Dictionary<Guid, string?> _values = [];
private readonly Dictionary<Guid, string?> _saved = [];
private readonly Dictionary<Guid, LearningVerdict?> _verdicts = [];
private readonly HashSet<Guid> _hints = [];
private readonly HashSet<TimeSpan> _announced = [];
private LearningAttemptDto? _attempt;
private Guid _loadedId;
private ITimer? _timer;
private TimeSpan _serverOffset;
private int _index;
private bool _submitting;
private bool _missing;
private bool _disposed;
private string? _error;
private string _announcement = string.Empty;
private SaveState _saveState;
[Parameter, EditorRequired]
public Guid AttemptId { get; set; }
private string QuestionId => $"fp-learning-q-{_index}";
private string InputId => $"fp-learning-answer-{_index}";
/// <summary>Verbleibende Zeit nach Serveruhr; <c>null</c> ohne Zeitlimit.</summary>
private TimeSpan? Remaining => _attempt?.DeadlineAt is { } deadline
? LearningRules.Remaining(deadline, TimeProvider.GetUtcNow() + _serverOffset)
: null;
protected override async Task OnParametersSetAsync()
{
if (AttemptId != _loadedId)
{
_loadedId = AttemptId;
await LoadAsync();
}
}
private async Task LoadAsync()
{
_error = null;
_missing = false;
var result = await Api.GetAttemptAsync(AttemptId);
if (_disposed)
{
return;
}
if (result.Succeeded && result.Value is { } attempt)
{
Apply(attempt);
}
else if (result.Problem?.Status == 404)
{
_missing = true;
}
else
{
_error = result.Problem?.Message ?? "Das Blatt konnte nicht geladen werden.";
}
}
/// <summary>Übernimmt den Serverstand: Antworten, Rückmeldungen, Uhrenabgleich und ggf. den Countdown.</summary>
private void Apply(LearningAttemptDto attempt)
{
_attempt = attempt;
_serverOffset = attempt.ServerNow - TimeProvider.GetUtcNow();
_values.Clear();
_saved.Clear();
_verdicts.Clear();
foreach (var item in attempt.Items)
{
_values[item.ItemId] = item.Answer;
_saved[item.ItemId] = item.Answer;
if (attempt.Mode == LearningMode.Practice && item.Feedback is { } feedback)
{
_verdicts[item.ItemId] = feedback.Verdict;
}
}
_index = Math.Clamp(_index, 0, Math.Max(0, attempt.Items.Count - 1));
if (attempt.Status == LearningAttemptStatus.InProgress && attempt.DeadlineAt is not null)
{
_timer ??= TimeProvider.CreateTimer(_ => _ = InvokeAsync(TickAsync), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
else
{
StopTimer();
}
}
private string? Value(Guid itemId) => _values.GetValueOrDefault(itemId);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// ---------- Uhr ----------
/// <summary>Jede Sekunde: Restzeit anzeigen, Hinweise vorlesen lassen und nach Ablauf automatisch abgeben (bei Netzfehlern erneut versuchen).</summary>
internal async Task TickAsync()
{
if (_disposed || _attempt is not { Status: LearningAttemptStatus.InProgress } || Remaining is not { } remaining)
{
return;
}
foreach (var threshold in Announcements)
{
if (remaining <= threshold && _announced.Add(threshold))
{
_announcement = LearningFormat.RemainingSpoken(remaining);
}
}
StateHasChanged();
if (remaining <= TimeSpan.Zero && !_submitting)
{
await SubmitAsync(auto: true);
}
}
private void StopTimer()
{
_timer?.Dispose();
_timer = null;
}
// ---------- Antworten speichern ----------
private Task SelectAsync(LearningAttemptItemDto item, int option)
{
_values[item.ItemId] = option.ToString(System.Globalization.CultureInfo.InvariantCulture);
return SaveAsync(item.ItemId);
}
private Task TextChangedAsync(LearningAttemptItemDto item, string? value)
{
_values[item.ItemId] = value;
return SaveAsync(item.ItemId);
}
/// <summary>
/// Speichert den aktuellen Wert einer Aufgabe. Alle Speicherungen laufen nacheinander (Sperre), damit eine ältere Antwort nie eine neuere
/// überschreibt; gespeichert wird immer der zuletzt eingegebene Wert.
/// </summary>
private async Task SaveAsync(Guid itemId)
{
await _saveLock.WaitAsync();
try
{
if (_disposed || _attempt is not { Status: LearningAttemptStatus.InProgress })
{
return;
}
var current = Normalize(Value(itemId));
if (current == Normalize(_saved.GetValueOrDefault(itemId)))
{
return;
}
_saveState = SaveState.Saving;
StateHasChanged();
var result = await Api.SaveAnswerAsync(AttemptId, itemId, new SaveAnswerRequest(current));
if (_disposed)
{
return;
}
if (result.Succeeded)
{
_saved[itemId] = current;
_verdicts[itemId] = _attempt.Mode == LearningMode.Practice ? result.Value?.Verdict : null;
_saveState = SaveState.Saved;
}
else if (result.Problem?.Status == 409)
{
// Abgegeben oder Zeit abgelaufen: Serverstand laden (zeigt das Ergebnis).
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
await LoadAsync();
}
else
{
_saveState = SaveState.Failed;
}
}
finally
{
_saveLock.Release();
if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
}
}
private async Task RetrySaveAsync()
{
if (_attempt is { } attempt)
{
await FlushAsync();
}
}
/// <summary>Speichert alle noch nicht gespeicherten Antworten; <c>false</c>, wenn etwas nicht gespeichert werden konnte.</summary>
private async Task<bool> FlushAsync()
{
if (_attempt is null)
{
return true;
}
foreach (var item in _attempt.Items.ToList())
{
await SaveAsync(item.ItemId);
if (_attempt is not { Status: LearningAttemptStatus.InProgress })
{
return true; // inzwischen abgegeben – nichts mehr zu speichern
}
}
return _saveState != SaveState.Failed;
}
private async Task GoToAsync(int index)
{
if (_attempt is null)
{
return;
}
await FlushAsync();
_index = Math.Clamp(index, 0, _attempt.Items.Count - 1);
}
// ---------- Abgabe ----------
private async Task RequestSubmitAsync()
{
if (_attempt is null || _submitting)
{
return;
}
var open = _attempt.Items.Count(i => Normalize(Value(i.ItemId)) is null);
var message = open == 0
? "Du hast alle Aufgaben beantwortet. Nach der Abgabe kannst du nichts mehr ändern."
: open == 1
? "Eine Aufgabe ist noch ohne Antwort. Nach der Abgabe kannst du nichts mehr ändern."
: $"{open} Aufgaben sind noch ohne Antwort. Nach der Abgabe kannst du nichts mehr ändern.";
var confirmed = await DialogService.ShowMessageBoxAsync("Wirklich abgeben?", message, yesText: "Ja, abgeben", cancelText: "Weiterarbeiten");
if (confirmed == true)
{
await SubmitAsync(auto: false);
}
}
private async Task SubmitAsync(bool auto)
{
if (_submitting || _attempt is not { Status: LearningAttemptStatus.InProgress })
{
return;
}
_submitting = true;
StateHasChanged();
try
{
await FlushAsync();
if (_attempt is not { Status: LearningAttemptStatus.InProgress })
{
return; // beim Speichern stellte sich heraus, dass der Versuch schon abgegeben ist
}
var result = await Api.SubmitAsync(AttemptId);
if (_disposed)
{
return;
}
if (result.Succeeded && result.Value is { } submitted)
{
Apply(submitted);
if (auto)
{
Snackbar.Add("Die Zeit ist abgelaufen – deine Antworten wurden abgegeben.", Severity.Warning);
}
}
else if (!auto || result.Problem?.Status is not null)
{
Snackbar.Add(result.Problem?.Message ?? "Die Abgabe hat nicht geklappt.", Severity.Warning);
}
// Bei Netzfehlern nach Zeitablauf läuft der Timer weiter und versucht es in der nächsten Sekunde erneut.
}
finally
{
_submitting = false;
if (!_disposed)
{
await InvokeAsync(StateHasChanged);
}
}
}
public ValueTask DisposeAsync()
{
_disposed = true;
StopTimer();
return ValueTask.CompletedTask;
}
}
@@ -0,0 +1,292 @@
@using System.Globalization
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@* Aufgabe anlegen oder ändern. Der Dialog ruft die Api selbst auf und liefert den neuen Stand des Blatts (inkl. aktueller Version) zurück. *@
<MudDialog>
<DialogContent>
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-item-form" id="learning-item-form">
<MudStack Spacing="2">
@if (_conflict)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="fp-learning-conflict">
Die Aufgabe wurde inzwischen von jemand anderem geändert. Der aktuelle Stand wurde geladen – bitte erneut speichern.
</MudAlert>
}
@if (_generalError is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="fp-dialog-error">@_generalError</MudAlert>
}
<MudTextField @bind-Value="_question" Label="Frage" InputId="item-question" Variant="Variant.Outlined" Lines="3" AutoFocus="true"
Error="@HasError("Question")" ErrorText="@ErrorFor("Question")" />
<MudSelect T="LearningAnswerType" @bind-Value="_type" Label="Antworttyp" Variant="Variant.Outlined" Class="fp-item-type"
ToStringFunc="@(t => LearningFormat.AnswerType(t))" Error="@HasError("AnswerType")" ErrorText="@ErrorFor("AnswerType")">
@foreach (var type in Types)
{
<MudSelectItem T="LearningAnswerType" Value="@type">@LearningFormat.AnswerType(type)</MudSelectItem>
}
</MudSelect>
@switch (_type)
{
case LearningAnswerType.Number:
<MudTextField @bind-Value="_correct" Label="Musterlösung (Zahl)" InputId="item-correct" Variant="Variant.Outlined" HelperText="Zum Beispiel 12,5"
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
<MudTextField @bind-Value="_tolerance" Label="Toleranz (optional)" InputId="item-tolerance" Variant="Variant.Outlined"
HelperText="Erlaubte Abweichung, z. B. 0,5 – leer heißt exakt" Error="@HasError("NumberTolerance")" ErrorText="@ErrorFor("NumberTolerance")" />
break;
case LearningAnswerType.Text:
<MudTextField @bind-Value="_correct" Label="Musterlösung" InputId="item-correct" Variant="Variant.Outlined"
HelperText="Mehrere richtige Schreibweisen mit | trennen. Weicht die Antwort ab, entscheiden die Eltern."
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
break;
case LearningAnswerType.SingleChoice:
<fieldset class="fp-item-options" role="radiogroup">
<legend class="mud-typography mud-typography-caption">Antwortmöglichkeiten – die richtige markieren</legend>
@for (var i = 0; i < _options.Count; i++)
{
var index = i;
<div class="d-flex align-center gap-1 mb-1 fp-item-option">
<MudTextField T="string" Value="@_options[index]" ValueChanged="v => _options[index] = v ?? string.Empty" Label="@($"Option {index + 1}")"
Variant="Variant.Outlined" Margin="Margin.Dense" Class="flex-grow-1" />
<button type="button" role="radio" aria-checked="@(_correctIndex == index ? "true" : "false")"
class="@($"fp-item-correct {(_correctIndex == index ? "is-correct" : string.Empty)}")"
aria-label="@($"Option {index + 1} ist richtig")" title="Richtige Antwort" @onclick="() => _correctIndex = index">
<MudIcon Icon="@(_correctIndex == index ? Icons.Material.Filled.CheckCircle : Icons.Material.Outlined.RadioButtonUnchecked)"
Color="@(_correctIndex == index ? Color.Success : Color.Default)" />
</button>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="() => RemoveOption(index)" Disabled="@(_options.Count <= LearningItem.MinOptions)"
aria-label="@($"Option {index + 1} entfernen")" Class="fp-item-option-remove" />
</div>
}
@if (HasError("CorrectAnswer"))
{
<MudText Typo="Typo.caption" Color="Color.Error">@ErrorFor("CorrectAnswer")</MudText>
}
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Add" OnClick="AddOption" Disabled="@(_options.Count >= LearningItem.MaxOptions)"
Class="fp-item-option-add">Option hinzufügen</MudButton>
</fieldset>
break;
}
<div class="d-flex flex-wrap gap-2">
<MudSelect T="int" Value="_difficulty" ValueChanged="OnDifficultyChanged" Label="Schwierigkeit" Variant="Variant.Outlined" Class="fp-task-field fp-item-difficulty"
ToStringFunc="@(d => LearningFormat.Difficulty(d))">
@for (var d = LearningItem.MinDifficulty; d <= LearningItem.MaxDifficulty; d++)
{
<MudSelectItem T="int" Value="@d">@LearningFormat.Difficulty(d)</MudSelectItem>
}
</MudSelect>
<MudNumericField T="int" Value="_points" ValueChanged="OnPointsChanged" Label="Punkte" Min="LearningItem.MinPoints" Max="LearningItem.MaxPoints"
Variant="Variant.Outlined" Class="fp-task-field fp-item-points" Error="@HasError("Points")" ErrorText="@ErrorFor("Points")" />
</div>
<MudTextField @bind-Value="_hint" Label="Hinweis für das Kind (optional, nur in der Übung sichtbar)" InputId="item-hint" Variant="Variant.Outlined" Lines="2"
Error="@HasError("Hint")" ErrorText="@ErrorFor("Hint")" />
<MudTextField @bind-Value="_explanation" Label="Erklärung zur Lösung (optional, nach der Abgabe)" InputId="item-explanation" Variant="Variant.Outlined" Lines="2"
Error="@HasError("Explanation")" ErrorText="@ErrorFor("Explanation")" />
</MudStack>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_busy">Abbrechen</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_busy" Class="fp-item-save">
@(Item is null ? "Aufgabe hinzufügen" : "Speichern")
</MudButton>
</DialogActions>
</MudDialog>
@code {
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice];
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
[Parameter, EditorRequired]
public Guid SetId { get; set; }
/// <summary>Vorhandene Aufgabe zum Bearbeiten; <c>null</c> = neue Aufgabe.</summary>
[Parameter]
public LearningItemDto? Item { get; set; }
private string _question = string.Empty;
private LearningAnswerType _type = LearningAnswerType.Number;
private string? _correct;
private string? _tolerance;
private List<string> _options = ["", ""];
private int _correctIndex;
private string? _hint;
private string? _explanation;
private int _difficulty = 1;
private int _points = 1;
private bool _pointsTouched;
private byte[]? _version;
private bool _busy;
private bool _conflict;
private string? _generalError;
private Dictionary<string, string> _errors = [];
protected override void OnInitialized()
{
if (Item is { } item)
{
Load(item);
}
}
private void Load(LearningItemDto item)
{
_question = item.Question;
_type = item.AnswerType == LearningAnswerType.Paper ? LearningAnswerType.Text : item.AnswerType;
_tolerance = item.NumberTolerance?.ToString(CultureInfo.GetCultureInfo("de-DE"));
if (item.AnswerType == LearningAnswerType.SingleChoice)
{
_options = [.. item.Options];
_correctIndex = int.TryParse(item.CorrectAnswer, NumberStyles.None, CultureInfo.InvariantCulture, out var index) ? index : 0;
}
else
{
_correct = item.CorrectAnswer;
}
_hint = item.Hint;
_explanation = item.Explanation;
_difficulty = item.Difficulty;
_points = item.Points;
_pointsTouched = true;
_version = item.Version;
}
private void OnDifficultyChanged(int difficulty)
{
_difficulty = difficulty;
if (!_pointsTouched)
{
_points = LearningItemRules.DefaultPoints(difficulty); // Vorschlag: Punkte nach Schwierigkeit
}
}
private void OnPointsChanged(int points)
{
_points = points;
_pointsTouched = true;
}
private void AddOption()
{
if (_options.Count < LearningItem.MaxOptions)
{
_options.Add(string.Empty);
}
}
private void RemoveOption(int index)
{
if (_options.Count <= LearningItem.MinOptions)
{
return;
}
_options.RemoveAt(index);
if (_correctIndex == index)
{
_correctIndex = 0;
}
else if (_correctIndex > index)
{
_correctIndex--;
}
}
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private LearningItemInput Build()
{
decimal? tolerance = null;
if (_type == LearningAnswerType.Number && LearningNumbers.ParseFirst(_tolerance) is { } parsed)
{
tolerance = Math.Abs(parsed);
}
return new LearningItemInput(
_question,
_type,
_type == LearningAnswerType.SingleChoice ? _correctIndex.ToString(CultureInfo.InvariantCulture) : _correct,
tolerance,
_type == LearningAnswerType.SingleChoice ? _options : null,
_hint,
_explanation,
_difficulty,
_points);
}
private async Task SaveAsync()
{
if (_busy)
{
return;
}
_generalError = null;
_conflict = false;
_errors = [];
var input = Build();
// Sofortiges Feedback mit denselben Regeln wie die Api (verbindlich ist die serverseitige Prüfung).
if (!string.IsNullOrWhiteSpace(_tolerance) && _type == LearningAnswerType.Number && LearningNumbers.ParseFirst(_tolerance) is null)
{
_errors["NumberTolerance"] = "Bitte eine Zahl eingeben.";
}
var validation = new LearningItemInputValidator().Validate(input);
foreach (var error in validation.Errors)
{
_errors.TryAdd(error.PropertyName, error.ErrorMessage);
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
var result = Item is null
? await Api.AddItemAsync(SetId, input)
: await Api.UpdateItemAsync(SetId, Item.Id, new UpdateLearningItemRequest(
input.Question, input.AnswerType, input.CorrectAnswer, input.NumberTolerance, input.Options, input.Hint, input.Explanation,
input.Difficulty, input.Points, _version));
_busy = false;
if (result.Succeeded && result.Value is { } detail)
{
Dialog?.Close(DialogResult.Ok(detail));
}
else if (result.Problem?.Status == 409 && result.Value is { } current && Item is { } edited)
{
// Jemand anderes war schneller: aktuellen Stand der Aufgabe laden, Eingaben des Benutzers bleiben stehen.
_conflict = true;
_version = current.Items.FirstOrDefault(i => i.Id == edited.Id)?.Version ?? _version;
}
else if (result.Problem?.Errors is { Count: > 0 })
{
foreach (var (field, messages) in result.Problem.Errors)
{
_errors[field] = string.Join(" ", messages);
}
}
else
{
_generalError = result.Problem?.Message ?? "Die Aufgabe konnte nicht gespeichert werden.";
}
}
private void Cancel() => Dialog?.Cancel();
}
@@ -0,0 +1,199 @@
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
@inject ApiAuthenticationStateProvider AuthState
@* Kinderansicht „Lernen“: offene Blätter mit großen Karten, Fortschritt und Ergebnis. *@
<section class="fp-learning-kid" aria-label="Meine Lernblätter">
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">Lernen</MudText>
<MudText Typo="Typo.subtitle1" Class="mb-3 fp-learning-greeting">Hallo @(AuthState.CurrentUser?.DisplayName)! Hier sind deine Übungsblätter.</MudText>
@if (_loading)
{
<LoadingIndicator />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else if (_sets.Count == 0)
{
<MudText Typo="Typo.h6" Class="my-6 text-center fp-learning-empty">Gerade gibt es kein Übungsblatt für dich. 🎉</MudText>
}
else
{
var open = _sets.Where(s => s.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress).ToList();
var finished = _sets.Where(s => s.Status is LearningSetStatus.Submitted or LearningSetStatus.Reviewed).ToList();
<MudText Typo="Typo.overline" HtmlTag="h2" Class="fp-category-header">Zu erledigen</MudText>
@if (open.Count == 0)
{
<MudText Class="mb-3 fp-learning-nothing-open">Alles erledigt – super gemacht!</MudText>
}
else
{
<ul class="fp-learning-cards" aria-label="Offene Blätter">
@foreach (var set in open)
{
@Card(set)
}
</ul>
}
@if (finished.Count > 0)
{
<MudText Typo="Typo.overline" HtmlTag="h2" Class="fp-category-header mt-3">Abgegeben und geprüft</MudText>
<ul class="fp-learning-cards" aria-label="Abgegebene Blätter">
@foreach (var set in finished)
{
@Card(set)
}
</ul>
}
}
</section>
@code {
private List<MyLearningSetDto> _sets = [];
private bool _loading = true;
private string? _error;
private Guid? _starting;
private DateOnly Today => FamilyTime.Today(TimeProvider);
protected override async Task OnInitializedAsync()
{
Live.LearningChanged += OnLearningChanged;
Live.Connected += LoadAsync;
await Live.EnsureStartedAsync();
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => LoadAsync();
private async Task LoadAsync()
{
var result = await Api.GetMineAsync();
if (result.Succeeded && result.Value is { } sets)
{
_sets = sets;
_error = null;
}
else if (_sets.Count == 0)
{
_error = result.Problem?.Message ?? "Die Übungsblätter konnten nicht geladen werden.";
}
_loading = false;
await InvokeAsync(StateHasChanged);
}
private RenderFragment Card(MyLearningSetDto set) => @<li class="@($"fp-learning-card {(LearningFormat.IsOverdue(set.DueDate, Today) && set.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress ? "is-overdue" : string.Empty)}")" data-set-id="@set.Id">
<div class="fp-learning-card-text">
<span class="fp-learning-title">@(set.Mode == LearningMode.Exam ? "📝 " : "✏️ ")@set.Title</span>
<span class="fp-learning-meta">
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@set.Subject</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@LearningFormat.GradeLevel(set.GradeLevel)</MudChip>
<MudChip T="string" Size="Size.Small" Color="@(set.Mode == LearningMode.Exam ? Color.Warning : Color.Default)" Variant="Variant.Outlined" Class="fp-learning-mode">
@LearningFormat.Mode(set.Mode)@(set.Mode == LearningMode.Exam && set.TimeLimitMinutes is { } minutes ? $" · {minutes} Min." : string.Empty)
</MudChip>
<span class="fp-learning-count">@(set.ItemCount == 1 ? "1 Aufgabe" : $"{set.ItemCount} Aufgaben")</span>
@if (set.DueDate is not null)
{
<span class="fp-learning-due">@LearningFormat.Due(set.DueDate, Today)</span>
}
</span>
@if (set.MaxAttempts > 0)
{
<span class="fp-learning-meta fp-learning-attempts">Versuch @Math.Min(set.AttemptsUsed + (set.RunningAttemptId is null ? 1 : 0), set.MaxAttempts) von @set.MaxAttempts</span>
}
@if (set.Results.Count > 0)
{
var latest = set.Results[0];
<span class="fp-learning-result" aria-label="Letztes Ergebnis">
@(latest.IsFinal ? "Geprüft" : "Abgegeben"): @LearningFormat.Points(latest.EarnedPoints) von @latest.TotalItemPoints@(latest.Grade is { } grade ? $" · {LearningFormat.Grade(grade)}" : string.Empty)@(!latest.IsFinal ? " (wird von Mama oder Papa geprüft)" : string.Empty)
</span>
}
@if (set.PointsCredited > 0)
{
<span class="fp-learning-points">⭐ @LearningFormat.Points(set.PointsCredited) gutgeschrieben</span>
}
</div>
<div class="fp-learning-card-actions">
@if (set.RunningAttemptId is { } running)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" StartIcon="@Icons.Material.Filled.PlayArrow"
Href="@($"lernen/versuch/{running}")" Class="fp-learning-continue" aria-label="@($"„{set.Title}“ weitermachen")">Weitermachen</MudButton>
}
else if (set.CanStart)
{
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Large" StartIcon="@Icons.Material.Filled.PlayArrow"
OnClick="() => StartAsync(set)" Disabled="@(_starting is not null)" Class="fp-learning-start"
aria-label="@($"„{set.Title}“ {(set.Results.Count == 0 ? "starten" : "noch einmal versuchen")}")">@(set.Results.Count == 0 ? "Los geht's" : "Nochmal versuchen")</MudButton>
}
@if (set.Results.Count > 0)
{
<MudButton Variant="Variant.Outlined" Size="Size.Large" StartIcon="@Icons.Material.Filled.Assessment"
Href="@($"lernen/versuch/{set.Results[0].AttemptId}")" Class="fp-learning-result-link"
aria-label="@($"Ergebnis von „{set.Title}“ ansehen")">Ergebnis</MudButton>
}
</div>
</li>;
private async Task StartAsync(MyLearningSetDto set)
{
if (_starting is not null)
{
return;
}
if (set.Mode == LearningMode.Exam)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Prüfung beginnen?",
LearningFormat.ExamRules(set.TimeLimitMinutes),
yesText: "Prüfung beginnen",
cancelText: "Noch nicht");
if (confirmed != true)
{
return;
}
}
_starting = set.Id;
try
{
var result = await Api.StartAsync(set.Id);
if (result.Succeeded && result.Value is { } attempt)
{
Navigation.NavigateTo($"lernen/versuch/{attempt.Id}");
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Das Blatt konnte nicht gestartet werden.", Severity.Warning);
await LoadAsync();
}
}
finally
{
_starting = null;
}
}
public void Dispose()
{
Live.LearningChanged -= OnLearningChanged;
Live.Connected -= LoadAsync;
}
}
@@ -0,0 +1,12 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
namespace FamilyPlanner.Web.Client.Components.Learning;
/// <summary>Nur zum Ein-/Ausblenden der Oberfläche; erzwungen wird serverseitig (LearningManage/LearningSolve).</summary>
public static class LearningAccess
{
public static bool CanManage(CurrentUserDto? user) => user?.Permissions?.ContainsKey(Permission.LearningManage) == true;
public static bool CanSolve(CurrentUserDto? user) => user?.Permissions?.ContainsKey(Permission.LearningSolve) == true;
}
@@ -0,0 +1,163 @@
using System.Globalization;
using FamilyPlanner.Domain.Common;
using FamilyPlanner.Domain.Learning;
using MudBlazor;
namespace FamilyPlanner.Web.Client.Components.Learning;
/// <summary>Anzeigehilfen der Lernaufgaben (deutsche Texte, Zeiten in Europe/Berlin).</summary>
public static class LearningFormat
{
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
public static string Status(LearningSetStatus status) => status switch
{
LearningSetStatus.Draft => "Entwurf",
LearningSetStatus.Assigned => "Zugewiesen",
LearningSetStatus.InProgress => "In Arbeit",
LearningSetStatus.Submitted => "Abgegeben",
LearningSetStatus.Reviewed => "Geprüft",
_ => status.ToString(),
};
public static Color StatusColor(LearningSetStatus status) => status switch
{
LearningSetStatus.Draft => Color.Default,
LearningSetStatus.Assigned => Color.Info,
LearningSetStatus.InProgress => Color.Primary,
LearningSetStatus.Submitted => Color.Warning,
LearningSetStatus.Reviewed => Color.Success,
_ => Color.Default,
};
public static string Mode(LearningMode mode) => mode == LearningMode.Exam ? "Prüfung" : "Übung";
public static string AnswerType(LearningAnswerType type) => type switch
{
LearningAnswerType.Number => "Zahl",
LearningAnswerType.Text => "Text",
LearningAnswerType.SingleChoice => "Auswahl",
LearningAnswerType.Paper => "Papier",
_ => type.ToString(),
};
public static string Verdict(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => "Richtig",
LearningVerdict.Partial => "Teilweise richtig",
LearningVerdict.Wrong => "Falsch",
_ => "Wird geprüft",
};
public static Color VerdictColor(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => Color.Success,
LearningVerdict.Partial => Color.Warning,
LearningVerdict.Wrong => Color.Error,
_ => Color.Info,
};
public static string VerdictIcon(LearningVerdict verdict) => verdict switch
{
LearningVerdict.Correct => Icons.Material.Filled.CheckCircle,
LearningVerdict.Partial => Icons.Material.Filled.Adjust,
LearningVerdict.Wrong => Icons.Material.Filled.Cancel,
_ => Icons.Material.Filled.HourglassTop,
};
public static string Grader(LearningGrader grader) => grader switch
{
LearningGrader.Parent => "von den Eltern bewertet",
LearningGrader.Ai => "von der KI vorgeschlagen",
_ => "automatisch bewertet",
};
public static string Difficulty(int difficulty) => difficulty switch
{
<= 1 => "Leicht",
2 => "Mittel",
_ => "Schwer",
};
public static string GradeLevel(int level) => $"{level}. Klasse";
public static string Points(int points) => points == 1 ? "1 Punkt" : $"{points} Punkte";
/// <summary>„Note 2 – gut“.</summary>
public static string Grade(int grade) => $"Note {grade} – {LearningGrades.Label(grade)}";
public static string Attempts(int maxAttempts) => maxAttempts switch
{
<= 0 => "beliebig oft",
1 => "1 Versuch",
_ => $"{maxAttempts} Versuche",
};
public static string TimeLimit(int? minutes) => minutes is > 0 ? $"{minutes} Min." : "ohne Zeitlimit";
/// <summary>Restzeit als <c>m:ss</c> bzw. <c>h:mm:ss</c>.</summary>
public static string Countdown(TimeSpan remaining)
{
if (remaining < TimeSpan.Zero)
{
remaining = TimeSpan.Zero;
}
var totalSeconds = (long)Math.Ceiling(remaining.TotalSeconds);
var hours = totalSeconds / 3600;
var minutes = totalSeconds % 3600 / 60;
var seconds = totalSeconds % 60;
return hours > 0 ? $"{hours}:{minutes:00}:{seconds:00}" : $"{minutes}:{seconds:00}";
}
/// <summary>Zeitangabe zum Vorlesen: „noch 4 Minuten“ / „noch 30 Sekunden“.</summary>
public static string RemainingSpoken(TimeSpan remaining)
{
var seconds = (int)Math.Ceiling(Math.Max(0, remaining.TotalSeconds));
return seconds >= 120
? $"Noch {(int)Math.Ceiling(seconds / 60.0)} Minuten."
: seconds >= 60 ? "Noch eine Minute." : $"Noch {seconds} Sekunden.";
}
/// <summary>„fällig heute“, „fällig morgen“, „fällig Fr., 16.10.“ bzw. „überfällig“.</summary>
public static string Due(DateOnly? due, DateOnly today)
{
if (due is not { } date)
{
return string.Empty;
}
return (date.DayNumber - today.DayNumber) switch
{
< 0 => $"überfällig seit {date.ToString("dd.MM.", German)}",
0 => "fällig heute",
1 => "fällig morgen",
_ when date.Year == today.Year => $"fällig {date.ToString("ddd", German).TrimEnd('.')}., {date.ToString("dd.MM.", German)}",
_ => $"fällig {date.ToString("dd.MM.yyyy", German)}",
};
}
public static bool IsOverdue(DateOnly? due, DateOnly today) => due is { } date && date < today;
/// <summary>Datum und Uhrzeit in der Familienzeitzone („12.10.2026 10:30“).</summary>
public static string Local(DateTimeOffset utc) => FamilyTime.ToFamilyTime(utc).ToString("dd.MM.yyyy HH:mm", German);
/// <summary>Antwort lesbar machen: bei Auswahlaufgaben den Text der gewählten Option statt des Index.</summary>
public static string AnswerText(LearningAnswerType type, IReadOnlyList<string> options, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "keine Antwort";
}
return type == LearningAnswerType.SingleChoice && int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var index) && index < options.Count
? options[index]
: value;
}
/// <summary>Kurze Beschreibung der Prüfungsregeln für das Kind vor dem Start.</summary>
public static string ExamRules(int? timeLimitMinutes) =>
timeLimitMinutes is > 0
? $"Du hast {timeLimitMinutes} Minuten. Die Uhr startet sofort und läuft weiter, auch wenn du die Seite schließt. Hinweise und Lösungen gibt es erst nach der Abgabe."
: "Hinweise und Lösungen gibt es erst nach der Abgabe.";
}
@@ -0,0 +1,222 @@
@using FamilyPlanner.Domain.Common
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@implements IDisposable
@inject ILearningApi Api
@inject ILiveConnection Live
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@inject TimeProvider TimeProvider
@* Elternansicht „Lernen“: Blätter verwalten und Prüf-Warteschlange (ADR-0021). *@
<section class="fp-learning-parent" aria-label="Lernen verwalten">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h4" HtmlTag="h1">Lernen</MudText>
<MudSpacer />
<MudButton Href="lernen/neu" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" Class="fp-learning-new">Neues Blatt</MudButton>
</div>
<div class="d-flex align-center flex-wrap gap-1 mb-3" role="tablist" aria-label="Bereiche">
<MudChip T="string" Color="@(_tab == Tab.Sets ? Color.Primary : Color.Default)" Variant="@(_tab == Tab.Sets ? Variant.Filled : Variant.Outlined)" OnClick="() => _tab = Tab.Sets"
role="tab" aria-selected="@(_tab == Tab.Sets ? "true" : "false")" Class="fp-learning-tab-sets">Blätter (@_sets.Count)</MudChip>
<MudChip T="string" Color="@(_tab == Tab.Review ? Color.Primary : _queue.Count > 0 ? Color.Warning : Color.Default)" Variant="@(_tab == Tab.Review ? Variant.Filled : Variant.Outlined)"
OnClick="() => _tab = Tab.Review" role="tab" aria-selected="@(_tab == Tab.Review ? "true" : "false")" Class="fp-learning-tab-review">Zu prüfen (@_queue.Count)</MudChip>
</div>
@if (_loading)
{
<LoadingIndicator />
}
else if (_error is not null)
{
<MudAlert Severity="Severity.Warning" Class="fp-learning-error">
@_error
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="LoadAsync">Erneut versuchen</MudButton>
</MudAlert>
}
else if (_tab == Tab.Review)
{
@if (_queue.Count == 0)
{
<MudText Class="my-6 text-center fp-learning-queue-empty">Nichts zu prüfen – alle Abgaben sind bestätigt. 🎉</MudText>
}
else
{
<ul class="fp-learning-queue" aria-label="Zu prüfen">
@foreach (var entry in _queue)
{
<li class="fp-learning-queue-item" data-attempt-id="@entry.AttemptId">
<div class="fp-learning-card-text">
<span class="fp-learning-title">@entry.ChildName – @entry.SetTitle</span>
<span class="fp-learning-meta">
<span>Versuch @entry.Number · @LearningFormat.Mode(entry.Mode) · abgegeben @LearningFormat.Local(entry.SubmittedAt)</span>
@if (entry.AutoSubmitted)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Variant="Variant.Outlined" Class="fp-learning-auto">Zeit abgelaufen</MudChip>
}
<span>@LearningFormat.Points(entry.EarnedPoints) von @entry.TotalItemPoints</span>
@if (entry.OpenCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Class="fp-learning-open">@(entry.OpenCount == 1 ? "1 Antwort offen" : $"{entry.OpenCount} Antworten offen")</MudChip>
}
</span>
</div>
<MudButton Href="@($"lernen/pruefen/{entry.AttemptId}")" Variant="Variant.Filled" Color="Color.Primary" Class="fp-learning-review-button"
aria-label="@($"{entry.SetTitle} von {entry.ChildName} prüfen")">Prüfen</MudButton>
</li>
}
</ul>
}
}
else
{
<div class="d-flex flex-wrap gap-2 mb-2 fp-learning-filters">
<MudSelect T="LearningSetStatus?" @bind-Value="_statusFilter" Label="Status" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-filter"
ToStringFunc="@(s => s is { } v ? LearningFormat.Status(v) : string.Empty)">
@foreach (var status in Enum.GetValues<LearningSetStatus>())
{
<MudSelectItem T="LearningSetStatus?" Value="@status">@LearningFormat.Status(status)</MudSelectItem>
}
</MudSelect>
<MudSelect T="Guid?" @bind-Value="_childFilter" Label="Kind" Variant="Variant.Outlined" Margin="Margin.Dense" Clearable="true" Class="fp-filter"
ToStringFunc="@(id => id is { } v ? NameOf(v) : string.Empty)">
@foreach (var child in _children)
{
<MudSelectItem T="Guid?" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
</div>
var visible = _sets.Where(s => (_statusFilter is null || s.Status == _statusFilter) && (_childFilter is null || s.TargetUserId == _childFilter)).ToList();
@if (visible.Count == 0)
{
<MudText Class="my-6 text-center fp-learning-empty">
@(_sets.Count == 0 ? "Noch keine Übungsblätter. Lege das erste Blatt an." : "Keine Blätter für diesen Filter.")
</MudText>
}
else
{
<ul class="fp-learning-list" aria-label="Übungsblätter">
@foreach (var set in visible)
{
<li class="fp-learning-list-item" data-set-id="@set.Id">
<div class="fp-learning-card-text">
<a href="@($"lernen/blatt/{set.Id}")" class="fp-learning-title">@set.Title</a>
<span class="fp-learning-meta">
<MudChip T="string" Size="Size.Small" Color="@LearningFormat.StatusColor(set.Status)" Class="fp-learning-status">@LearningFormat.Status(set.Status)</MudChip>
<span>@NameOf(set.TargetUserId)</span>
<span>@set.Subject · @LearningFormat.GradeLevel(set.GradeLevel)</span>
<span>@LearningFormat.Mode(set.Mode)@(set.TimeLimitMinutes is { } minutes ? $" · {minutes} Min." : string.Empty)</span>
<span>@(set.ItemCount == 1 ? "1 Aufgabe" : $"{set.ItemCount} Aufgaben")</span>
@if (set.DueDate is not null)
{
<span class="@(LearningFormat.IsOverdue(set.DueDate, Today) && set.Status is LearningSetStatus.Assigned or LearningSetStatus.InProgress ? "fp-learning-overdue" : null)">@LearningFormat.Due(set.DueDate, Today)</span>
}
@if (set.PendingReviewCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="fp-learning-pending">@(set.PendingReviewCount == 1 ? "1 zu prüfen" : $"{set.PendingReviewCount} zu prüfen")</MudChip>
}
@if (set.PointsCredited != 0)
{
<span>@LearningFormat.Points(set.PointsCredited) gutgeschrieben</span>
}
</span>
</div>
<div class="fp-learning-card-actions">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Href="@($"lernen/blatt/{set.Id}")" aria-label="@($"„{set.Title}“ bearbeiten")" Class="fp-learning-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteAsync(set)" aria-label="@($"„{set.Title}“ löschen")" Class="fp-learning-delete" />
</div>
</li>
}
</ul>
}
}
</section>
@code {
private enum Tab
{
Sets,
Review,
}
private List<LearningSetDto> _sets = [];
private List<LearningReviewQueueItemDto> _queue = [];
private List<UserDirectoryEntry> _children = [];
private Dictionary<Guid, string> _names = [];
private Tab _tab = Tab.Sets;
private LearningSetStatus? _statusFilter;
private Guid? _childFilter;
private bool _loading = true;
private string? _error;
private DateOnly Today => FamilyTime.Today(TimeProvider);
protected override async Task OnInitializedAsync()
{
Live.LearningChanged += OnLearningChanged;
Live.Connected += LoadAsync;
await Live.EnsureStartedAsync();
await LoadAsync();
}
private Task OnLearningChanged(LearningChangedDto _) => LoadAsync();
private async Task LoadAsync()
{
var sets = await Api.GetSetsAsync();
var queue = await Api.GetReviewQueueAsync();
var users = await Api.GetUsersAsync();
if (users.Succeeded && users.Value is { } list)
{
_names = list.ToDictionary(u => u.Id, u => u.DisplayName);
_children = list.Where(u => u.Role == Roles.Child).OrderBy(u => u.DisplayName, StringComparer.CurrentCultureIgnoreCase).ToList();
}
if (sets.Succeeded && sets.Value is { } loaded)
{
_sets = loaded;
_queue = queue.Succeeded && queue.Value is { } q ? q : _queue;
_error = null;
}
else if (_sets.Count == 0)
{
_error = sets.Problem?.Message ?? "Die Übungsblätter konnten nicht geladen werden.";
}
_loading = false;
await InvokeAsync(StateHasChanged);
}
private string NameOf(Guid userId) => _names.GetValueOrDefault(userId, "Unbekannt");
private async Task DeleteAsync(LearningSetDto set)
{
var hint = set.AttemptCount > 0 ? " Bereits vergebene Punkte bleiben im Punktebuch bestehen." : string.Empty;
var confirmed = await DialogService.ShowMessageBoxAsync(
"Blatt löschen?", $"„{set.Title}“ wirklich löschen?{hint}", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true)
{
return;
}
var result = await Api.DeleteSetAsync(set.Id);
if (result.Succeeded)
{
_sets.RemoveAll(s => s.Id == set.Id);
_queue.RemoveAll(q => q.SetId == set.Id);
Snackbar.Add("Das Blatt ist gelöscht.", Severity.Success);
}
else
{
Snackbar.Add(result.Problem?.Message ?? "Das Blatt konnte nicht gelöscht werden.", Severity.Warning);
}
}
public void Dispose()
{
Live.LearningChanged -= OnLearningChanged;
Live.Connected -= LoadAsync;
}
}
@@ -0,0 +1,569 @@
@using System.Globalization
@using FamilyPlanner.Domain.Learning
@using FamilyPlanner.Shared.Admin
@using FamilyPlanner.Shared.Learning
@inject ILearningApi Api
@inject NavigationManager Navigation
@inject IDialogService DialogService
@inject ISnackbar Snackbar
@* Editor für ein Übungsblatt (Eltern): Einstellungen, Aufgaben, Zuweisen. Einstellungen zu Wiederholen/Zeitlimit/Modus/Punkten (ADR-0021). *@
<section class="fp-learning-editor" aria-label="Übungsblatt bearbeiten">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="lernen" aria-label="Zurück zur Übersicht" Class="fp-learning-editor-back" />
<MudText Typo="Typo.h4" HtmlTag="h1">@(SetId is null ? "Neues Übungsblatt" : "Übungsblatt")</MudText>
@if (_detail is { } head)
{
<MudChip T="string" Color="@LearningFormat.StatusColor(head.Set.Status)" Size="Size.Small" Class="fp-learning-status">@LearningFormat.Status(head.Set.Status)</MudChip>
}
</div>
@if (_loading)
{
<LoadingIndicator />
}
else if (_missing)
{
<MudAlert Severity="Severity.Info" Class="fp-learning-missing">Dieses Blatt gibt es nicht (mehr).</MudAlert>
}
else
{
@if (_conflict)
{
<MudAlert Severity="Severity.Warning" role="alert" Class="mb-2 fp-learning-conflict">
Das Blatt wurde inzwischen von jemand anderem geändert. Der aktuelle Stand ist geladen – bitte noch einmal speichern, wenn deine Änderungen bleiben sollen.
</MudAlert>
}
@if (_error is not null)
{
<MudAlert Severity="Severity.Error" role="alert" Class="mb-2 fp-dialog-error">@_error</MudAlert>
}
@if (Locked)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2 fp-learning-locked">
Es gibt schon Versuche: Aufgaben, Kind, Modus, Mischen, Punkte und Notenschlüssel lassen sich nicht mehr ändern.
</MudAlert>
}
<EditForm Model="this" OnSubmit="SaveAsync" FormName="learning-set-form" id="learning-set-form">
<MudPaper Outlined="true" Class="pa-3 mb-3">
<MudStack Spacing="2">
<MudTextField @bind-Value="_title" Label="Titel" InputId="set-title" Variant="Variant.Outlined" AutoFocus="@(SetId is null)"
Error="@HasError("Title")" ErrorText="@ErrorFor("Title")" />
<div class="d-flex flex-wrap gap-2">
<MudSelect T="string" @bind-Value="_subject" Label="Fach" Variant="Variant.Outlined" Class="fp-task-field fp-set-subject"
Error="@HasError("Subject")" ErrorText="@ErrorFor("Subject")">
@foreach (var subject in LearningSubjects.All)
{
<MudSelectItem T="string" Value="@subject">@subject</MudSelectItem>
}
</MudSelect>
<MudSelect T="int" @bind-Value="_gradeLevel" Label="Klassenstufe" Variant="Variant.Outlined" Class="fp-task-field fp-set-grade"
ToStringFunc="@(g => LearningFormat.GradeLevel(g))" Error="@HasError("GradeLevel")" ErrorText="@ErrorFor("GradeLevel")">
@for (var level = LearningSet.MinGradeLevel; level <= LearningSet.MaxGradeLevel; level++)
{
<MudSelectItem T="int" Value="@level">@LearningFormat.GradeLevel(level)</MudSelectItem>
}
</MudSelect>
<MudSelect T="Guid" @bind-Value="_target" Label="Kind" Variant="Variant.Outlined" Class="fp-task-field fp-set-target" Disabled="Locked"
ToStringFunc="@(id => _children.FirstOrDefault(c => c.Id == id)?.DisplayName ?? string.Empty)"
Error="@HasError("TargetUserId")" ErrorText="@ErrorFor("TargetUserId")">
@foreach (var child in _children)
{
<MudSelectItem T="Guid" Value="@child.Id">@child.DisplayName</MudSelectItem>
}
</MudSelect>
<MudTextField T="string" @bind-Value="_dueDate" Label="Fällig am (optional)" InputType="InputType.Date" InputId="set-due" Variant="Variant.Outlined"
Class="fp-task-field" Clearable="true" Error="@HasError("DueDate")" ErrorText="@ErrorFor("DueDate")" />
</div>
</MudStack>
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-settings">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Ablauf</MudText>
<MudStack Spacing="2">
<div role="radiogroup" aria-label="Modus" class="d-flex flex-wrap gap-2">
<MudChip T="string" Color="@(_mode == LearningMode.Practice ? Color.Primary : Color.Default)" Variant="@(_mode == LearningMode.Practice ? Variant.Filled : Variant.Outlined)"
OnClick="() => SetMode(LearningMode.Practice)" role="radio" aria-checked="@(_mode == LearningMode.Practice ? "true" : "false")"
Disabled="Locked" Class="fp-set-mode-practice">Übung – Sofort-Rückmeldung und Hinweise</MudChip>
<MudChip T="string" Color="@(_mode == LearningMode.Exam ? Color.Primary : Color.Default)" Variant="@(_mode == LearningMode.Exam ? Variant.Filled : Variant.Outlined)"
OnClick="() => SetMode(LearningMode.Exam)" role="radio" aria-checked="@(_mode == LearningMode.Exam ? "true" : "false")"
Disabled="Locked" Class="fp-set-mode-exam">Prüfung – keine Rückmeldung und keine Hilfen bis zur Abgabe</MudChip>
</div>
<div class="d-flex flex-wrap gap-2 align-start">
<MudNumericField T="int" Value="_maxAttempts" ValueChanged="OnAttemptsChanged" Label="Versuche (0 = unbegrenzt)" Min="0" Max="LearningSet.AttemptsLimit"
Variant="Variant.Outlined" Class="fp-task-field fp-set-attempts" Error="@HasError("MaxAttempts")" ErrorText="@ErrorFor("MaxAttempts")"
HelperText="@LearningFormat.Attempts(_maxAttempts)" />
<div class="fp-task-field">
<MudSwitch T="bool" Value="_hasTimeLimit" ValueChanged="OnTimeLimitToggled" Label="Zeitlimit" Color="Color.Primary" Class="fp-set-timelimit-switch" />
@if (_hasTimeLimit)
{
<MudNumericField T="int" @bind-Value="_timeLimit" Label="Minuten" Min="1" Max="LearningSet.MaxTimeLimitMinutes" Variant="Variant.Outlined"
Class="fp-set-timelimit" Error="@HasError("TimeLimitMinutes")" ErrorText="@ErrorFor("TimeLimitMinutes")"
HelperText="Die Uhr läuft auf dem Server und startet mit dem Versuch." />
}
</div>
</div>
<MudSwitch T="bool" @bind-Value="_showSolutions" Label="Lösungen nach der Abgabe zeigen" Color="Color.Primary" Class="fp-set-solutions" />
<MudSwitch T="bool" @bind-Value="_shuffle" Label="Aufgaben mischen (je Versuch stabil)" Color="Color.Primary" Disabled="Locked" Class="fp-set-shuffle" />
</MudStack>
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-points">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Punkte und Note</MudText>
<MudStack Spacing="2">
<MudNumericField T="int" @bind-Value="_totalPoints" Label="Punkte für das Blatt (0 = keine)" Min="0" Max="LearningSet.MaxTotalPoints" Variant="Variant.Outlined"
Disabled="Locked" Class="fp-set-total" Error="@HasError("TotalPoints")" ErrorText="@ErrorFor("TotalPoints")"
HelperText="Gutschrift anteilig nach erreichten Punkten, erst nach deiner Bestätigung; bei Wiederholung zählt die beste Wertung." />
@if (_mode == LearningMode.Exam)
{
<MudTextField @bind-Value="_thresholds" Label="Notenschlüssel (Mindestprozent für Note 1 bis 5)" Variant="Variant.Outlined" Disabled="Locked" Class="fp-set-thresholds"
Placeholder="@string.Join(';', LearningGrades.DefaultThresholds)" HelperText="Leer = Schulnoten 92;81;67;50;30. Darunter gibt es die 6."
Error="@HasError("GradeThresholds")" ErrorText="@ErrorFor("GradeThresholds")" />
}
</MudStack>
</MudPaper>
<div class="d-flex flex-wrap gap-2 mb-4">
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="_busy" Class="fp-set-save">
@(SetId is null ? "Blatt anlegen" : "Einstellungen speichern")
</MudButton>
<MudButton Href="lernen" Variant="Variant.Text">@(SetId is null ? "Abbrechen" : "Zur Übersicht")</MudButton>
</div>
</EditForm>
@if (_detail is { } detail)
{
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-items">
<div class="d-flex align-center flex-wrap gap-2 mb-2">
<MudText Typo="Typo.h6" HtmlTag="h2">Aufgaben (@detail.Items.Count)</MudText>
<MudSpacer />
@if (!Locked)
{
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditItemAsync(null)" Class="fp-set-add-item">Aufgabe hinzufügen</MudButton>
}
</div>
@if (detail.Items.Count == 0)
{
<MudText Class="fp-set-no-items">Noch keine Aufgaben – füge die erste hinzu.</MudText>
}
else
{
<ol class="fp-set-item-list">
@for (var i = 0; i < detail.Items.Count; i++)
{
var item = detail.Items[i];
var index = i;
<li class="fp-set-item" data-item-id="@item.Id">
<div class="fp-set-item-text">
<span class="fp-set-item-question">@item.Question</span>
<span class="fp-set-item-meta">
@LearningFormat.AnswerType(item.AnswerType) · @LearningFormat.Difficulty(item.Difficulty) · @LearningFormat.Points(item.Points)
· Lösung: @LearningFormat.AnswerText(item.AnswerType, item.Options, item.CorrectAnswer)
</span>
</div>
@if (!Locked)
{
<div class="fp-set-item-actions">
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward" Disabled="@(index == 0 || _busy)" OnClick="() => MoveAsync(index, -1)"
aria-label="@($"Aufgabe {index + 1} nach oben")" Class="fp-item-up" />
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward" Disabled="@(index == detail.Items.Count - 1 || _busy)" OnClick="() => MoveAsync(index, 1)"
aria-label="@($"Aufgabe {index + 1} nach unten")" Class="fp-item-down" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" OnClick="() => EditItemAsync(item)" aria-label="@($"Aufgabe {index + 1} bearbeiten")" Class="fp-item-edit" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" OnClick="() => DeleteItemAsync(item, index)"
aria-label="@($"Aufgabe {index + 1} löschen")" Class="fp-item-delete" />
</div>
}
</li>
}
</ol>
}
</MudPaper>
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-assign">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Zuweisen</MudText>
@if (detail.Set.Status == LearningSetStatus.Draft)
{
<MudText Typo="Typo.body2" Class="mb-2">Das Blatt ist noch ein Entwurf – das Kind sieht es erst nach dem Zuweisen.</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Send" OnClick="AssignAsync" Disabled="_busy" Class="fp-set-assign-button">
@(_children.FirstOrDefault(c => c.Id == detail.Set.TargetUserId) is { } child ? $"An {child.DisplayName} zuweisen" : "Zuweisen")
</MudButton>
}
else if (detail.Set.Status == LearningSetStatus.Assigned && detail.Set.AttemptCount == 0)
{
<MudText Typo="Typo.body2" Class="mb-2">Zugewiesen@(detail.Set.AssignedAt is { } at ? $" am {LearningFormat.Local(at)}" : string.Empty). Noch kein Versuch.</MudText>
<MudButton Variant="Variant.Outlined" OnClick="UnassignAsync" Disabled="_busy" Class="fp-set-unassign-button">Zurück zum Entwurf</MudButton>
}
else
{
<MudText Typo="Typo.body2">Zugewiesen@(detail.Set.AssignedAt is { } assigned ? $" am {LearningFormat.Local(assigned)}" : string.Empty).</MudText>
}
</MudPaper>
@if (detail.Attempts.Count > 0)
{
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-set-attempt-list">
<MudText Typo="Typo.h6" HtmlTag="h2" Class="mb-2">Versuche</MudText>
<ul class="fp-set-attempts-list">
@foreach (var attempt in detail.Attempts)
{
<li>
<span>Versuch @attempt.Number – @AttemptText(attempt)</span>
@if (attempt.Status != LearningAttemptStatus.InProgress)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Href="@($"lernen/pruefen/{attempt.Id}")" Class="fp-set-review-link">
@(attempt.Status == LearningAttemptStatus.Submitted ? "Prüfen" : "Ansehen")
</MudButton>
}
</li>
}
</ul>
</MudPaper>
}
}
}
</section>
@code {
private readonly List<UserDirectoryEntry> _children = [];
private LearningSetDetailDto? _detail;
private byte[]? _version;
private Guid? _loadedId;
private bool _loading = true;
private bool _missing;
private bool _busy;
private bool _conflict;
private string? _error;
private Dictionary<string, string> _errors = [];
private string _title = string.Empty;
private string _subject = LearningSubjects.Math;
private int _gradeLevel = 3;
private Guid _target;
private string? _dueDate;
private LearningMode _mode = LearningMode.Practice;
private int _maxAttempts;
private bool _attemptsTouched;
private bool _hasTimeLimit;
private int _timeLimit = 20;
private bool _showSolutions = true;
private bool _shuffle;
private int _totalPoints = 10;
private string? _thresholds;
[Parameter]
public Guid? SetId { get; set; }
private bool Locked => _detail?.Set.AttemptCount > 0;
protected override async Task OnParametersSetAsync()
{
if (_loadedId == SetId && !_loading)
{
return;
}
_loadedId = SetId;
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_missing = false;
var users = await Api.GetUsersAsync();
_children.Clear();
if (users.Succeeded && users.Value is { } list)
{
_children.AddRange(list.Where(u => u.Role == Roles.Child).OrderBy(u => u.DisplayName, StringComparer.CurrentCultureIgnoreCase));
}
if (SetId is { } id)
{
var result = await Api.GetSetAsync(id);
if (result.Succeeded && result.Value is { } detail)
{
_detail = detail;
LoadForm(detail.Set);
}
else
{
_missing = true;
}
}
else
{
_detail = null;
_version = null;
if (_children.Count == 1)
{
_target = _children[0].Id;
}
}
_loading = false;
}
private void LoadForm(LearningSetDto set)
{
_title = set.Title;
_subject = set.Subject;
_gradeLevel = set.GradeLevel;
_target = set.TargetUserId;
_dueDate = set.DueDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
_mode = set.Mode;
_maxAttempts = set.MaxAttempts;
_attemptsTouched = true;
_hasTimeLimit = set.TimeLimitMinutes is not null;
_timeLimit = set.TimeLimitMinutes ?? 20;
_showSolutions = set.ShowSolutionsAfterSubmit;
_shuffle = set.ShuffleItems;
_totalPoints = set.TotalPoints;
_thresholds = set.GradeThresholds;
_version = set.Version;
}
/// <summary>Voreinstellungen je Modus für neue Blätter: Übung beliebig oft mit Lösungen, Prüfung einmalig ohne Lösungen.</summary>
private void SetMode(LearningMode mode)
{
if (Locked || _mode == mode)
{
return;
}
_mode = mode;
if (SetId is null && !_attemptsTouched)
{
_maxAttempts = mode == LearningMode.Exam ? 1 : 0;
_showSolutions = mode == LearningMode.Practice;
}
}
private void OnAttemptsChanged(int value)
{
_maxAttempts = value;
_attemptsTouched = true;
}
private void OnTimeLimitToggled(bool on) => _hasTimeLimit = on;
private bool HasError(string field) => _errors.ContainsKey(field);
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private DateOnly? DueDate => DateOnly.TryParseExact(_dueDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null;
private async Task SaveAsync()
{
if (_busy)
{
return;
}
_error = null;
_conflict = false;
_errors = [];
int? timeLimit = _hasTimeLimit ? _timeLimit : null;
string? thresholds = _mode == LearningMode.Exam && !string.IsNullOrWhiteSpace(_thresholds) ? _thresholds.Trim() : null;
var validation = SetId is null
? new CreateLearningSetRequestValidator().Validate(new CreateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds))
: new UpdateLearningSetRequestValidator().Validate(new UpdateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds, _version));
foreach (var error in validation.Errors)
{
_errors.TryAdd(error.PropertyName, error.ErrorMessage);
}
if (!string.IsNullOrWhiteSpace(_dueDate) && DueDate is null)
{
_errors["DueDate"] = "Bitte ein gültiges Datum eingeben.";
}
if (_errors.Count > 0)
{
return;
}
_busy = true;
try
{
if (SetId is null)
{
var created = await Api.CreateSetAsync(new CreateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds));
if (created.Succeeded && created.Value is { } detail)
{
Snackbar.Add("Das Blatt ist angelegt – jetzt kannst du Aufgaben hinzufügen.", Severity.Success);
Navigation.NavigateTo($"lernen/blatt/{detail.Set.Id}");
}
else
{
ShowProblem(created.Problem, "Das Blatt konnte nicht angelegt werden.");
}
return;
}
var updated = await Api.UpdateSetAsync(SetId.Value, new UpdateLearningSetRequest(
_title, _subject, _gradeLevel, _target, DueDate, _mode, _maxAttempts, timeLimit, _showSolutions, _shuffle, _totalPoints, thresholds, _version));
if (updated.Succeeded && updated.Value is { } saved)
{
_detail = saved;
LoadForm(saved.Set);
Snackbar.Add("Gespeichert.", Severity.Success);
}
else if (updated.Problem?.Status == 409 && updated.Value is { } current)
{
// Jemand anderes war schneller: Version und Anzeige aktualisieren, Eingaben bleiben stehen.
_conflict = true;
_detail = current;
_version = current.Set.Version;
}
else
{
ShowProblem(updated.Problem, "Das Blatt konnte nicht gespeichert werden.");
}
}
finally
{
_busy = false;
}
}
private void ShowProblem(ApiProblem? problem, string fallback)
{
if (problem?.Errors is { Count: > 0 })
{
foreach (var (field, messages) in problem.Errors)
{
_errors[field] = string.Join(" ", messages);
}
}
else
{
_error = problem?.Message ?? fallback;
}
}
/// <summary>Übernimmt den Stand nach einer Aufgaben-Änderung, ohne ungespeicherte Eingaben des Formulars zu überschreiben.</summary>
private void ApplyDetail(LearningSetDetailDto detail)
{
_detail = detail;
_version = detail.Set.Version;
}
private async Task EditItemAsync(LearningItemDto? item)
{
if (_detail is not { } detail)
{
return;
}
var parameters = new DialogParameters<ItemEditDialog> { { x => x.SetId, detail.Set.Id }, { x => x.Item, item } };
var dialog = await DialogService.ShowAsync<ItemEditDialog>(
item is null ? "Aufgabe hinzufügen" : "Aufgabe bearbeiten", parameters, new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseOnEscapeKey = true });
var result = await dialog.Result;
if (result is { Canceled: false, Data: LearningSetDetailDto updated })
{
ApplyDetail(updated);
}
}
private async Task DeleteItemAsync(LearningItemDto item, int index)
{
var confirmed = await DialogService.ShowMessageBoxAsync(
"Aufgabe löschen?", $"Aufgabe {index + 1} wirklich löschen?", yesText: "Löschen", cancelText: "Abbrechen");
if (confirmed != true || _detail is not { } detail)
{
return;
}
await RunAsync(() => Api.DeleteItemAsync(detail.Set.Id, item.Id), "Die Aufgabe konnte nicht gelöscht werden.");
}
private async Task MoveAsync(int index, int delta)
{
if (_detail is not { } detail)
{
return;
}
var ids = detail.Items.Select(i => i.Id).ToList();
var target = index + delta;
if (target < 0 || target >= ids.Count)
{
return;
}
(ids[index], ids[target]) = (ids[target], ids[index]);
await RunAsync(() => Api.ReorderItemsAsync(detail.Set.Id, ids), "Die Reihenfolge konnte nicht geändert werden.");
}
private async Task AssignAsync()
{
if (_detail is not { } detail)
{
return;
}
if (await RunAsync(() => Api.AssignAsync(detail.Set.Id), "Das Blatt konnte nicht zugewiesen werden."))
{
Snackbar.Add("Das Blatt ist zugewiesen.", Severity.Success);
}
}
private async Task UnassignAsync()
{
if (_detail is { } detail)
{
await RunAsync(() => Api.UnassignAsync(detail.Set.Id), "Das Blatt konnte nicht zurückgenommen werden.");
}
}
private async Task<bool> RunAsync(Func<Task<ApiResult<LearningSetDetailDto>>> action, string fallback)
{
if (_busy)
{
return false;
}
_busy = true;
_error = null;
try
{
var result = await action();
if (result.Succeeded && result.Value is { } detail)
{
ApplyDetail(detail);
return true;
}
if (result.Value is { } current)
{
ApplyDetail(current);
}
_error = result.Problem?.Message ?? fallback;
return false;
}
finally
{
_busy = false;
}
}
private static string AttemptText(LearningAttemptSummaryDto attempt) => attempt.Status switch
{
LearningAttemptStatus.InProgress => "läuft",
LearningAttemptStatus.Submitted => attempt.AutoSubmitted ? "abgegeben (Zeit abgelaufen), wartet auf Prüfung" : "abgegeben, wartet auf Prüfung",
_ => $"geprüft: {attempt.EarnedPoints} von {attempt.TotalItemPoints} Punkten{(attempt.Grade is { } g ? $", Note {g}" : string.Empty)}",
};
}
@@ -3,7 +3,7 @@ using MudBlazor;
namespace FamilyPlanner.Web.Client.Layout;
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null);
public sealed record NavItem(string Href, string Title, string ShortTitle, string Icon, bool InBottomNav, string? Policy = null, bool Separated = false);
/// <summary>Zentrale Navigationseinträge für Drawer (Desktop) und Bottom-Navigation (Smartphone).</summary>
public static class NavItems
@@ -16,6 +16,7 @@ public static class NavItems
new("aufgaben", "Aufgaben", "Aufgaben", Icons.Material.Filled.TaskAlt, InBottomNav: true),
new("kalender", "Kalender", "Kalender", Icons.Material.Filled.CalendarMonth, InBottomNav: true),
new("chat", "Chat", "Chat", Icons.Material.Filled.Chat, InBottomNav: true),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin),
new("lernen", "Lernen", "Lernen", Icons.Material.Filled.School, InBottomNav: false, Policies.FamilyMember),
new("admin", "Verwaltung", "Admin", Icons.Material.Filled.AdminPanelSettings, InBottomNav: false, Policies.Admin, Separated: true),
];
}
@@ -19,7 +19,10 @@
else
{
<AuthorizeView Policy="@item.Policy">
<MudDivider Class="my-2" />
@if (item.Separated)
{
<MudDivider Class="my-2" />
}
<MudNavLink Href="@item.Href" Icon="@item.Icon" Match="NavLinkMatch.Prefix">@item.Title</MudNavLink>
</AuthorizeView>
}
@@ -217,6 +217,56 @@ else
</MudItem>
}
@if (_data.Learning is { } learning)
{
<MudItem xs="12" sm="6" lg="4">
<MudCard Outlined="true" Class="fp-tile fp-dash-learning">
<MudCardHeader>
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.School" Color="Color.Primary" /></CardHeaderAvatar>
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Lernen</MudText></CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (learning.ForParents)
{
<MudText Typo="Typo.body2" Class="mb-2 fp-dash-learning-summary">
@(learning.ToReviewCount == 0 ? "Nichts zu prüfen." : DashboardFormat.Count(learning.ToReviewCount, "Abgabe", "Abgaben") + " zu prüfen")
@if (learning.OpenCount > 0)
{
<MudChip T="string" Size="Size.Small" Class="ml-1 fp-dash-learning-open">@DashboardFormat.Count(learning.OpenCount, "Blatt", "Blätter") offen</MudChip>
}
</MudText>
}
else
{
<MudText Typo="Typo.body2" Class="mb-2 fp-dash-learning-summary">
@(learning.OpenCount == 0 ? "Keine offenen Übungsblätter – super!" : DashboardFormat.Count(learning.OpenCount, "offenes Übungsblatt", "offene Übungsblätter"))
</MudText>
}
@if (learning.Items.Count > 0)
{
<ul class="fp-dash-list fp-dash-learning-list">
@foreach (var entry in learning.Items)
{
<li>
<a href="@LearningHref(learning, entry)" class="fp-dash-main">@(entry.ChildName is { } child ? $"{child}: " : string.Empty)@entry.Title</a>
<span class="fp-dash-sub">
@entry.Subject@(entry.DueDate is { } due ? $" · {Components.Learning.LearningFormat.Due(due, Today)}" : string.Empty)
@if (entry.IsOverdue)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error" Class="ml-1 fp-dash-overdue">überfällig</MudChip>
}
</span>
</li>
}
</ul>
}
</MudCardContent>
<MudCardActions><MudButton Href="lernen" Variant="Variant.Text" Color="Color.Primary">Lernen</MudButton></MudCardActions>
</MudCard>
</MudItem>
}
@if (_data.Chat is { } chat)
{
<MudItem xs="12" sm="6" lg="4">
@@ -271,6 +321,7 @@ else
Live.TaskRemoved += OnTaskRemoved;
Live.TaskPointsChanged += OnLiveEvent;
Live.CalendarChanged += OnCalendarChanged;
Live.LearningChanged += OnLearningChanged;
Live.ChatMessageReceived += OnChatMessage;
Live.ChatMessageRemoved += OnChatRemoved;
Live.ChatReadChanged += OnChatRead;
@@ -340,6 +391,12 @@ else
private Task OnCalendarChanged(Guid _) => ScheduleRefreshAsync();
private Task OnLearningChanged(FamilyPlanner.Shared.Learning.LearningChangedDto _) => ScheduleRefreshAsync();
private static string LearningHref(DashboardLearningDto learning, DashboardLearningItemDto entry) => entry.AttemptId is { } attempt
? learning.ForParents ? $"lernen/pruefen/{attempt}" : $"lernen/versuch/{attempt}"
: "lernen";
private Task OnChatMessage(FamilyPlanner.Shared.Chat.ChatMessageDto _) => ScheduleRefreshAsync();
private Task OnChatRemoved(Guid _, Guid __) => ScheduleRefreshAsync();
@@ -388,6 +445,7 @@ else
Live.TaskRemoved -= OnTaskRemoved;
Live.TaskPointsChanged -= OnLiveEvent;
Live.CalendarChanged -= OnCalendarChanged;
Live.LearningChanged -= OnLearningChanged;
Live.ChatMessageReceived -= OnChatMessage;
Live.ChatMessageRemoved -= OnChatRemoved;
Live.ChatReadChanged -= OnChatRead;
@@ -0,0 +1,19 @@
@page "/lernen"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Lernen – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<ParentOverview />
}
else if (LearningAccess.CanSolve(AuthState.CurrentUser))
{
<KidOverview />
}
else
{
<MudText Typo="Typo.h4" HtmlTag="h1" Class="mb-2">Lernen</MudText>
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Dieser Bereich ist für dein Konto nicht freigeschaltet.</MudAlert>
}
@@ -0,0 +1,19 @@
@page "/lernen/versuch/{AttemptId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Blatt lösen – Familienplaner</PageTitle>
@if (LearningAccess.CanSolve(AuthState.CurrentUser))
{
<AttemptRunner @key="AttemptId" AttemptId="AttemptId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Dieser Bereich ist für dein Konto nicht freigeschaltet.</MudAlert>
}
@code {
[Parameter]
public Guid AttemptId { get; set; }
}
@@ -0,0 +1,19 @@
@page "/lernen/pruefen/{AttemptId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Versuch prüfen – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<AttemptReview AttemptId="AttemptId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Übungsblätter prüfen können nur Eltern.</MudAlert>
}
@code {
[Parameter]
public Guid AttemptId { get; set; }
}
@@ -0,0 +1,20 @@
@page "/lernen/neu"
@page "/lernen/blatt/{SetId:guid}"
@using FamilyPlanner.Web.Client.Components.Learning
@inject ApiAuthenticationStateProvider AuthState
<PageTitle>Übungsblatt – Familienplaner</PageTitle>
@if (LearningAccess.CanManage(AuthState.CurrentUser))
{
<SetEditor SetId="SetId" />
}
else
{
<MudAlert Severity="Severity.Info" Class="fp-learning-no-access">Übungsblätter können nur Eltern anlegen und bearbeiten.</MudAlert>
}
@code {
[Parameter]
public Guid? SetId { get; set; }
}
+1
View File
@@ -34,6 +34,7 @@ builder.Services.AddScoped<ICalendarApi, CalendarApi>();
builder.Services.AddScoped<IDashboardApi, DashboardApi>();
builder.Services.AddScoped<IChatApi, ChatApi>();
builder.Services.AddScoped<IAiApi, AiApi>();
builder.Services.AddScoped<ILearningApi, LearningApi>();
builder.Services.AddScoped<IPushService, PushService>();
builder.Services.AddScoped<ILocalStore, BrowserLocalStore>();
builder.Services.AddScoped<ILiveConnection, LiveConnection>();
@@ -0,0 +1,197 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.Web.Client.Services;
/// <summary>
/// Api der Lernaufgaben (ADR-0021). Eltern-Aufrufe (<c>api/learning</c>) brauchen <c>LearningManage</c>, Kinder-Aufrufe
/// (<c>api/learning/my</c>) <c>LearningSolve</c>; erzwungen wird serverseitig.
/// </summary>
public interface ILearningApi
{
// ---- Eltern ----
Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default);
/// <summary>Bei 409 wegen veralteter Version enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand.</summary>
Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default);
Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default);
/// <summary>Bei 409 wegen veralteter Version enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand des Blatts.</summary>
Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default);
Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default);
Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default);
Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default);
Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default);
Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default);
Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default);
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default);
// ---- Kinder ----
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
/// <summary>Startet einen Versuch (bei Prüfungen beginnt die Uhr) oder setzt den laufenden fort.</summary>
Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default);
Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default);
/// <summary>Speichert eine Antwort; bei 409 (abgegeben oder Zeit abgelaufen) enthält <see cref="ApiResult{T}.Problem"/> die Meldung.</summary>
Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default);
Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default);
}
public sealed class LearningApi(HttpClient http) : ILearningApi
{
private const string Base = "api/learning";
public Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default) =>
Send<List<LearningSetDto>>(() => http.GetAsync($"{Base}/sets", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.GetAsync($"{Base}/sets/{id}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets", request, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PutAsJsonAsync($"{Base}/sets/{id}", request, ct), ct, currentOnConflict: true);
public Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default) =>
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/sets/{id}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsync($"{Base}/sets/{id}/assign", null, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsync($"{Base}/sets/{id}/unassign", null, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets/{setId}/items", request, ct), ct);
public Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PutAsJsonAsync($"{Base}/sets/{setId}/items/{itemId}", request, ct), ct, currentOnConflict: true);
public Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.DeleteAsync($"{Base}/sets/{setId}/items/{itemId}", ct), ct);
public Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets/{setId}/items/order", new ReorderLearningItemsRequest(itemIds), ct), ct);
public Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default) =>
Send<List<LearningReviewQueueItemDto>>(() => http.GetAsync($"{Base}/review", ct), ct);
public Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptReviewDto>(() => http.GetAsync($"{Base}/attempts/{attemptId}", ct), ct);
public Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default) =>
Send<LearningAttemptReviewDto>(() => http.PutAsJsonAsync($"{Base}/attempts/{attemptId}/answers/{itemId}/review", request, ct), ct);
public Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default) =>
Send<LearningReviewResultDto>(() => http.PostAsJsonAsync($"{Base}/attempts/{attemptId}/confirm", request, ct), ct);
public Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningReviewResultDto>(() => http.PostAsync($"{Base}/attempts/{attemptId}/reopen", null, ct), ct);
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
Send<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
public Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/{setId}/attempts", null, ct), ct);
public Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.GetAsync($"{Base}/my/attempts/{attemptId}", ct), ct);
public Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default) =>
Send<SaveAnswerResultDto>(() => http.PutAsJsonAsync($"{Base}/my/attempts/{attemptId}/answers/{itemId}", request, ct), ct);
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default) =>
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/submit", null, ct), ct);
/// <summary>
/// 409 ist hier meist eine fachliche Meldung (Problem-Details). Nur wenn <paramref name="currentOnConflict"/> gesetzt ist und der Body ein
/// Blatt (<c>set</c>) enthält, steht darin der aktuelle Stand nach einer Versionskollision.
/// </summary>
private static async Task<ApiResult<T>> Send<T>(Func<Task<HttpResponseMessage>> send, CancellationToken ct, bool currentOnConflict = false)
{
try
{
using var response = await send();
if (response.StatusCode == HttpStatusCode.Conflict && currentOnConflict)
{
var text = await response.Content.ReadAsStringAsync(ct);
if (TryReadCurrent<T>(text) is { } current)
{
return new ApiResult<T>(current, new ApiProblem { Status = 409, Detail = "Das Blatt wurde inzwischen von jemand anderem geändert." });
}
return ApiResult<T>.Fail(ReadProblem(text, response.StatusCode));
}
if (!response.IsSuccessStatusCode)
{
return ApiResult<T>.Fail(await ApiClient.ReadProblemAsync(response, ct));
}
return response.StatusCode == HttpStatusCode.NoContent
? ApiResult<T>.Ok(default)
: ApiResult<T>.Ok(await response.Content.ReadFromJsonAsync<T>(ct));
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(ApiProblem.Offline);
}
}
private static T? TryReadCurrent<T>(string json)
{
try
{
using var document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Object && document.RootElement.TryGetProperty("set", out _)
? document.RootElement.Deserialize<T>(new JsonSerializerOptions(JsonSerializerDefaults.Web))
: default;
}
catch (JsonException)
{
return default;
}
}
private static ApiProblem ReadProblem(string json, HttpStatusCode status)
{
try
{
return JsonSerializer.Deserialize<ApiProblem>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new ApiProblem { Status = (int)status };
}
catch (JsonException)
{
return new ApiProblem { Status = (int)status };
}
}
}
@@ -1,5 +1,6 @@
using FamilyPlanner.Shared.Calendar;
using FamilyPlanner.Shared.Chat;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Shared.Meals;
using FamilyPlanner.Shared.Shopping;
using FamilyPlanner.Shared.Tasks;
@@ -43,6 +44,9 @@ public interface ILiveConnection
event Func<Task>? TaskPointsChanged;
/// <summary>Lernblatt oder Versuch geändert (ohne Inhalte; nur für die Zielperson und Eltern) – Daten neu laden.</summary>
event Func<LearningChangedDto, Task>? LearningChanged;
/// <summary>Termine/Kalender geändert (Kalender-Id, <see cref="Guid.Empty"/> = Kalenderliste/Rechte).</summary>
event Func<Guid, Task>? CalendarChanged;
@@ -87,6 +91,7 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
_hub.On<TaskDto>(TaskEvents.TaskChanged, dto => Raise(TaskChanged, h => h(dto)));
_hub.On<Guid>(TaskEvents.TaskRemoved, id => Raise(TaskRemoved, h => h(id)));
_hub.On(TaskEvents.PointsChanged, () => Raise(TaskPointsChanged, h => h()));
_hub.On<LearningChangedDto>(LearningEvents.Changed, dto => Raise(LearningChanged, h => h(dto)));
_hub.On<Guid>(CalendarEvents.Changed, calendarId => Raise(CalendarChanged, h => h(calendarId)));
_hub.On<ChatMessageDto>(ChatEvents.MessageReceived, dto => Raise(ChatMessageReceived, h => h(dto)));
_hub.On<Guid, Guid>(ChatEvents.MessageRemoved, (channelId, messageId) => Raise(ChatMessageRemoved, h => h(channelId, messageId)));
@@ -135,6 +140,8 @@ public sealed class LiveConnection : ILiveConnection, IAsyncDisposable
public event Func<Task>? TaskPointsChanged;
public event Func<LearningChangedDto, Task>? LearningChanged;
public event Func<Guid, Task>? CalendarChanged;
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
+328
View File
@@ -1471,3 +1471,331 @@ button.fp-meal-dish {
max-width: 92%;
}
}
/* Lernen (ADR-0021): Kinderansicht, Elternansicht, Editor, Prüfung */
.fp-learning-cards,
.fp-learning-list,
.fp-learning-queue,
.fp-learning-answers,
.fp-review-answers,
.fp-set-item-list,
.fp-set-attempts-list,
.fp-learning-dots {
list-style: none;
margin: 0;
padding: 0;
}
.fp-learning-card,
.fp-learning-list-item,
.fp-learning-queue-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
padding: 14px 16px;
border-radius: 16px;
background: var(--mud-palette-surface);
border: 2px solid var(--mud-palette-lines-default);
}
.fp-learning-list-item,
.fp-learning-queue-item {
border-width: 1px;
border-radius: 12px;
padding: 10px 14px;
}
.fp-learning-card.is-overdue {
border-color: var(--mud-palette-error);
}
.fp-learning-card-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fp-learning-title {
font-size: 1.2rem;
font-weight: 600;
color: inherit;
text-decoration: none;
overflow-wrap: anywhere;
}
a.fp-learning-title:hover {
text-decoration: underline;
}
.fp-learning-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
color: var(--mud-palette-text-secondary);
}
.fp-learning-overdue {
color: var(--mud-palette-error);
font-weight: 600;
}
.fp-learning-card-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.fp-learning-card-actions .mud-button-root {
min-height: 52px;
min-width: 120px;
}
.fp-learning-list-item .fp-learning-card-actions .mud-button-root {
min-height: 0;
min-width: 0;
}
.fp-learning-result,
.fp-learning-points {
font-weight: 500;
}
/* Lösen */
.fp-learning-run-title {
overflow-wrap: anywhere;
}
.fp-learning-timer {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
border: 2px solid var(--mud-palette-lines-default);
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fp-learning-timer.is-urgent {
border-color: var(--mud-palette-error);
color: var(--mud-palette-error);
}
.fp-learning-progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.fp-learning-progress-text {
color: var(--mud-palette-text-secondary);
font-size: .9rem;
}
.fp-learning-dots {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.fp-learning-dot {
min-width: 44px;
min-height: 44px;
border-radius: 50%;
border: 2px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.fp-learning-dot.is-answered {
background: var(--mud-palette-success-lighten);
border-color: var(--mud-palette-success);
}
.fp-learning-dot.is-current {
border-color: var(--mud-palette-primary);
box-shadow: 0 0 0 2px var(--mud-palette-primary);
}
.fp-learning-dot:focus-visible,
.fp-learning-choice:focus-visible,
.fp-item-correct:focus-visible {
outline: 3px solid var(--mud-palette-primary);
outline-offset: 2px;
}
.fp-learning-question {
margin: 0 0 12px;
font-size: 1.25rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.fp-learning-choices {
display: flex;
flex-direction: column;
gap: 10px;
}
.fp-learning-choice {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 10px 16px;
border-radius: 14px;
border: 2px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
color: inherit;
font: inherit;
font-size: 1.1rem;
text-align: left;
cursor: pointer;
}
.fp-learning-choice.is-selected {
border-color: var(--mud-palette-primary);
background: var(--mud-palette-primary-hover);
}
.fp-learning-choice-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid currentColor;
font-weight: 700;
}
.fp-learning-input input,
.fp-learning-input textarea {
font-size: 1.25rem !important;
}
.fp-learning-save-state {
min-height: 1.5rem;
margin-top: 4px;
color: var(--mud-palette-text-secondary);
font-size: .9rem;
}
.fp-learning-save-failed {
color: var(--mud-palette-error);
}
.fp-link-button {
background: none;
border: 0;
padding: 0;
color: var(--mud-palette-primary);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.fp-learning-nav .mud-button-root,
.fp-review-actions .mud-button-root {
min-height: 52px;
min-width: 112px;
}
/* Ergebnis */
.fp-learning-answer,
.fp-review-answer {
margin-bottom: 12px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--mud-palette-lines-default);
background: var(--mud-palette-surface);
}
.fp-learning-answer p,
.fp-review-answer p {
margin: 4px 0;
}
.fp-learning-explanation {
color: var(--mud-palette-text-secondary);
}
/* Editor */
.fp-set-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--mud-palette-lines-default);
}
.fp-set-item-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.fp-set-item-question {
font-weight: 600;
overflow-wrap: anywhere;
}
.fp-set-item-meta {
color: var(--mud-palette-text-secondary);
font-size: .875rem;
}
.fp-set-item-actions {
display: flex;
flex-wrap: wrap;
}
.fp-item-options {
border: 0;
margin: 0;
padding: 0;
}
.fp-item-correct {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 0;
border-radius: 50%;
background: none;
cursor: pointer;
}
.fp-set-attempts-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 0;
}
@media (max-width: 599.98px) {
.fp-learning-card,
.fp-learning-queue-item {
flex-direction: column;
align-items: stretch;
}
.fp-learning-card-actions .mud-button-root {
flex: 1;
}
}
@@ -55,6 +55,7 @@ public sealed class DashboardTests(AppFixture fixture)
Assert.NotNull(parent.MyTasks);
Assert.NotNull(parent.FamilyTasks);
Assert.NotNull(parent.Chat);
Assert.True(parent.Learning is { ForParents: true });
Assert.Equal(FamilyTime.Today(TimeProvider.System), parent.Today);
Assert.Equal([parent.Today, parent.Today.AddDays(1)], parent.Events.Select(d => d.Date));
@@ -64,6 +65,7 @@ public sealed class DashboardTests(AppFixture fixture)
Assert.NotNull(kid.MyTasks);
Assert.NotNull(kid.Chat);
Assert.Null(kid.FamilyTasks);
Assert.True(kid.Learning is { ForParents: false });
var visitor = await GetAsync(guest);
Assert.NotNull(visitor.Meals);
@@ -73,6 +75,7 @@ public sealed class DashboardTests(AppFixture fixture)
Assert.Empty(visitor.MyTasks.Items);
Assert.Null(visitor.FamilyTasks);
Assert.Null(visitor.Chat);
Assert.Null(visitor.Learning);
}
[Fact]
@@ -0,0 +1,178 @@
using System.Net;
using System.Net.Http.Json;
using System.Threading.Channels;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Shared.Tasks;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using static FamilyPlanner.IntegrationTests.LearningSupport;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Punkte-Workflow der Lernblätter (ADR-0021 Punkt 10): Gutschrift erst nach Elternbestätigung, anteilig, beste Wertung zählt einmal,
/// Differenz wird gebucht, Rückbuchung beim erneuten Öffnen. Läuft in der Settings-Collection, weil das Punktesystem eine Familieneinstellung ist.
/// </summary>
[Collection(SettingsCollection.Name)]
public sealed class LearningPointsTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task<int> BalanceAsync(HttpClient client, Guid userId) =>
(await client.GetFromJsonAsync<PointsOverviewDto>("/api/tasks/points", Ct))!.Balances.Single(b => b.UserId == userId).Points;
/// <summary>Kind löst: Zahl richtig (2 P.), Auswahl richtig (1 P.), Text wie gewünscht – Freitext wird von den Eltern bewertet.</summary>
private static async Task<LearningAttemptDto> SolveAsync(HttpClient kid, LearningAttemptDto attempt, bool numberCorrect, bool choiceCorrect, string text)
{
await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, numberCorrect ? "4711,5" : "1");
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, choiceCorrect ? "0" : "2");
await SaveAsync(kid, attempt.Id, attempt.Items[2].ItemId, text);
return await SubmitAsync(kid, attempt.Id);
}
[Fact]
public async Task Points_are_credited_proportionally_only_after_confirmation_and_the_best_attempt_counts_once()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10, MaxAttempts = 0 });
var textItem = set.Items[2].Id;
// Versuch 1: nur die Zahl richtig, Freitext falsch → 2 von 4 Punkten = 50 % von 10 = 5 Punkte
var first = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: true, choiceCorrect: false, text: "Pudel");
Assert.Equal(0, await BalanceAsync(admin, child.Id)); // erst nach der Bestätigung
await ReviewAsync(parent, first.Id, textItem, LearningVerdict.Wrong);
Assert.Equal(0, await BalanceAsync(admin, child.Id));
var confirmed = await ConfirmAsync(parent, first.Id);
Assert.Equal((5, 5), (confirmed.PointsDelta, confirmed.PointsCredited));
Assert.Equal(5, await BalanceAsync(admin, child.Id));
Assert.Equal(5, (await GetSetAsync(parent, set.Set.Id)).Set.PointsCredited);
Assert.Equal(5, Assert.Single(await MineAsync(kid)).PointsCredited);
// Audit-Log: Gutschrift mit Blatt und Kind
var audit = (await admin.GetFromJsonAsync<FamilyPlanner.Shared.PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct))!;
Assert.Contains(audit.Items, e => e.Action == "learning.points_credited" && e.TargetName == set.Set.Title && e.Details!.Contains("+5 Punkte", StringComparison.Ordinal));
// Bestätigen ist idempotent: keine Doppelbuchung
Assert.Equal((0, 5), ((await ConfirmAsync(parent, first.Id)).PointsDelta, (await ConfirmAsync(parent, first.Id)).PointsCredited));
Assert.Equal(5, await BalanceAsync(admin, child.Id));
// Versuch 2 ist besser (3 von 4 = 75 % → 8 Punkte, kaufmännisch gerundet aus 7,5): nur die Differenz (+3) wird gebucht
var second = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: true, choiceCorrect: true, text: "Pudel");
await ReviewAsync(parent, second.Id, textItem, LearningVerdict.Wrong);
var improved = await ConfirmAsync(parent, second.Id);
Assert.Equal((3, 8), (improved.PointsDelta, improved.PointsCredited));
Assert.Equal(8, await BalanceAsync(admin, child.Id));
// Versuch 3 ist schlechter: nichts wird gebucht oder abgezogen (beste Wertung zählt)
var third = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: false, choiceCorrect: false, text: "Pudel");
await ReviewAsync(parent, third.Id, textItem, LearningVerdict.Wrong);
Assert.Equal((0, 8), ((await ConfirmAsync(parent, third.Id)).PointsDelta, 8));
Assert.Equal(8, await BalanceAsync(admin, child.Id));
// Versuch 2 erneut öffnen: Rückbuchung auf die nächstbeste Wertung (Versuch 1: 5 Punkte)
var reopened = await ReopenAsync(parent, second.Id);
Assert.Equal((-3, 5), (reopened.PointsDelta, reopened.PointsCredited));
Assert.Equal(LearningAttemptStatus.Submitted, reopened.Attempt.Status);
Assert.Equal(5, await BalanceAsync(admin, child.Id));
Assert.Equal(LearningSetStatus.Submitted, (await GetSetAsync(parent, set.Set.Id)).Set.Status); // wartet wieder auf Bestätigung
// Korrigieren und erneut bestätigen: Freitext war doch richtig → 4 von 4 → 10 Punkte
await ReviewAsync(parent, second.Id, textItem, LearningVerdict.Correct);
var corrected = await ConfirmAsync(parent, second.Id);
Assert.Equal((5, 10), (corrected.PointsDelta, corrected.PointsCredited));
Assert.Equal(10, await BalanceAsync(admin, child.Id));
Assert.Equal(LearningSetStatus.Reviewed, (await GetSetAsync(parent, set.Set.Id)).Set.Status);
// Buchungen sind im Punktebuch nachvollziehbar (Kind sieht nur eigene)
var overview = (await kid.GetFromJsonAsync<PointsOverviewDto>("/api/tasks/points", Ct))!;
Assert.Contains(overview.Recent, e => e.UserId == child.Id && e.Points == -3 && e.Reason.StartsWith("Rückbuchung", StringComparison.Ordinal));
Assert.All(overview.Recent, e => Assert.Equal(child.Id, e.UserId));
}
[Fact]
public async Task Sheets_without_points_book_nothing_and_a_partial_review_scores_partially()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var admin = await fixture.CreateAdminClientAsync(Ct);
// Kein Punktesystem am Blatt: nichts wird gebucht
var plain = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 0 });
var attempt = await SolveAsync(kid, await StartAsync(kid, plain.Set.Id), true, true, SecretText);
var confirmed = await ConfirmAsync(parent, attempt.Id);
Assert.Equal((0, 0), (confirmed.PointsDelta, confirmed.PointsCredited));
Assert.Equal(0, await BalanceAsync(admin, child.Id));
// Teilpunkte: Aufgabe mit 4 Punkten, Eltern geben 3 → 3 von 4
var items = new List<LearningItemInput> { new("Erkläre den Wasserkreislauf.", LearningAnswerType.Text, "Verdunsten, Kondensieren, Regnen", Points: 4) };
var open = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 20, Items = items });
var second = await StartAsync(kid, open.Set.Id);
await SaveAsync(kid, second.Id, second.Items[0].ItemId, "Wasser verdunstet und regnet ab.");
await SubmitAsync(kid, second.Id);
var review = await ReviewAsync(parent, second.Id, open.Items[0].Id, LearningVerdict.Partial, 3);
Assert.Equal((3, 4, 0), (review.EarnedPoints, review.TotalItemPoints, review.OpenCount));
Assert.Equal(HttpStatusCode.BadRequest,
(await parent.PutAsJsonAsync($"/api/learning/attempts/{second.Id}/answers/{open.Items[0].Id}/review", new ReviewAnswerRequest(LearningVerdict.Partial, 4), Ct)).StatusCode);
var result = await ConfirmAsync(parent, second.Id);
Assert.Equal((15, 15), (result.PointsDelta, result.PointsCredited)); // 3/4 von 20
Assert.Equal(15, await BalanceAsync(admin, child.Id));
}
[Fact]
public async Task Disabled_point_system_books_nothing_and_points_changes_are_announced_to_the_family()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
// Live-Ereignis „Punkte geändert“ beim Bestätigen
var points = Channel.CreateUnbounded<bool>();
var cookies = new System.Net.CookieContainer();
using var login = fixture.CreateApiClient(cookies);
await AppFixture.LoginAsync(login, child.UserName, AppFixture.UserPassword, Ct);
await using var hub = new HubConnectionBuilder()
.WithUrl(new Uri(fixture.WebBaseAddress, "/hubs/live"), options =>
{
options.Cookies = cookies;
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
options.HttpMessageHandlerFactory = _ => new HttpClientHandler
{
CookieContainer = cookies,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
options.WebSocketConfiguration = ws => ws.RemoteCertificateValidationCallback = (_, _, _, _) => true;
})
.Build();
hub.On(TaskEvents.PointsChanged, () => points.Writer.TryWrite(true));
await hub.StartAsync(Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10 });
var attempt = await SolveAllCorrectlyAsync(kid, await StartAsync(kid, set.Set.Id));
await ConfirmAsync(parent, attempt.Id);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
Assert.True(await points.Reader.ReadAsync(timeout.Token));
// Punktesystem aus: Bestätigen bucht nichts (und ändert nichts an bestehenden Buchungen)
try
{
Assert.Equal(HttpStatusCode.OK, (await admin.PutAsJsonAsync("/api/admin/settings", settings with { ChorePointsEnabled = false }, Ct)).StatusCode);
var before = await BalanceAsync(admin, child.Id);
var set2 = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10 });
var attempt2 = await SolveAllCorrectlyAsync(kid, await StartAsync(kid, set2.Set.Id));
var confirmed = await ConfirmAsync(parent, attempt2.Id);
Assert.Equal(0, confirmed.PointsDelta);
Assert.Equal(before, await BalanceAsync(admin, child.Id));
}
finally
{
await admin.PutAsJsonAsync("/api/admin/settings", settings, Ct);
}
}
}
@@ -0,0 +1,128 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.IntegrationTests;
/// <summary>Gemeinsame Hilfen der Lernaufgaben-Integrationstests (Aufrufe über den BFF wie im Browser).</summary>
internal static class LearningSupport
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// Eindeutige Marker, die in Kinder-Antworten nie vorkommen dürfen, solange die Regeln es nicht erlauben.
public const string SecretNumber = "4711,5";
public const string SecretText = "Dackelwurst";
public const string SecretHint = "HINWEIS-GEHEIM-77";
public const string SecretExplanation = "ERKLAERUNG-GEHEIM-88";
/// <summary>Drei Aufgaben: Zahl (2 Punkte), Auswahl (1 Punkt, richtig = Index 0), Text (1 Punkt) – zusammen 4 Aufgabenpunkte.</summary>
public static List<LearningItemInput> StandardItems() =>
[
new("Was ergibt 9423 : 2?", LearningAnswerType.Number, SecretNumber, NumberTolerance: 0.1m, Hint: SecretHint, Explanation: SecretExplanation, Difficulty: 2, Points: 2),
new("Hauptstadt von Deutschland?", LearningAnswerType.SingleChoice, "0", Options: ["Berlin", "Paris", "Rom"], Points: 1),
new("Nenne einen Hund mit kurzen Beinen.", LearningAnswerType.Text, SecretText, Points: 1),
];
public static CreateLearningSetRequest NewSet(Guid childId, string? title = null) => new(
title ?? $"Blatt {Guid.NewGuid():N}"[..14],
LearningSubjects.Math,
3,
childId,
MaxAttempts: 0,
TotalPoints: 10,
Items: StandardItems());
public static async Task<LearningSetDetailDto> CreateAsync(HttpClient parent, CreateLearningSetRequest request, bool assign = true)
{
var response = await parent.PostAsJsonAsync("/api/learning/sets", request, Ct);
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
var detail = (await response.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
return assign ? await AssignAsync(parent, detail.Set.Id) : detail;
}
public static async Task<LearningSetDetailDto> AssignAsync(HttpClient parent, Guid setId)
{
var response = await parent.PostAsync($"/api/learning/sets/{setId}/assign", null, Ct);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
}
public static async Task<LearningSetDetailDto> GetSetAsync(HttpClient parent, Guid setId) =>
(await parent.GetFromJsonAsync<LearningSetDetailDto>($"/api/learning/sets/{setId}", Ct))!;
public static async Task<List<MyLearningSetDto>> MineAsync(HttpClient kid) =>
(await kid.GetFromJsonAsync<List<MyLearningSetDto>>("/api/learning/my", Ct))!;
public static async Task<LearningAttemptDto> StartAsync(HttpClient kid, Guid setId)
{
var response = await kid.PostAsync($"/api/learning/my/{setId}/attempts", null, Ct);
Assert.True(response.StatusCode is HttpStatusCode.Created or HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningAttemptDto>(Ct))!;
}
public static async Task<LearningAttemptDto> GetAttemptAsync(HttpClient kid, Guid attemptId) =>
(await kid.GetFromJsonAsync<LearningAttemptDto>($"/api/learning/my/attempts/{attemptId}", Ct))!;
public static Task<HttpResponseMessage> SaveRawAsync(HttpClient kid, Guid attemptId, Guid itemId, string? value) =>
kid.PutAsJsonAsync($"/api/learning/my/attempts/{attemptId}/answers/{itemId}", new SaveAnswerRequest(value), Ct);
public static async Task<SaveAnswerResultDto> SaveAsync(HttpClient kid, Guid attemptId, Guid itemId, string? value)
{
var response = await SaveRawAsync(kid, attemptId, itemId, value);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<SaveAnswerResultDto>(Ct))!;
}
public static async Task<LearningAttemptDto> SubmitAsync(HttpClient kid, Guid attemptId)
{
var response = await kid.PostAsync($"/api/learning/my/attempts/{attemptId}/submit", null, Ct);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningAttemptDto>(Ct))!;
}
public static async Task<LearningReviewResultDto> ConfirmAsync(HttpClient parent, Guid attemptId)
{
var response = await parent.PostAsJsonAsync($"/api/learning/attempts/{attemptId}/confirm", new ConfirmAttemptRequest(), Ct);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningReviewResultDto>(Ct))!;
}
public static async Task<LearningReviewResultDto> ReopenAsync(HttpClient parent, Guid attemptId)
{
var response = await parent.PostAsync($"/api/learning/attempts/{attemptId}/reopen", null, Ct);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningReviewResultDto>(Ct))!;
}
public static async Task<LearningAttemptReviewDto> ReviewAsync(HttpClient parent, Guid attemptId, Guid itemId, LearningVerdict verdict, int? points = null)
{
var response = await parent.PutAsJsonAsync(
$"/api/learning/attempts/{attemptId}/answers/{itemId}/review", new ReviewAnswerRequest(verdict, points, "Geprüft"), Ct);
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
return (await response.Content.ReadFromJsonAsync<LearningAttemptReviewDto>(Ct))!;
}
public static async Task<LearningAttemptReviewDto> GetReviewAsync(HttpClient parent, Guid attemptId) =>
(await parent.GetFromJsonAsync<LearningAttemptReviewDto>($"/api/learning/attempts/{attemptId}", Ct))!;
public static async Task<List<LearningReviewQueueItemDto>> QueueAsync(HttpClient parent) =>
(await parent.GetFromJsonAsync<List<LearningReviewQueueItemDto>>("/api/learning/review", Ct))!;
/// <summary>Antwortet das Kind „perfekt“ (alle drei Standardaufgaben richtig) und gibt ab.</summary>
public static async Task<LearningAttemptDto> SolveAllCorrectlyAsync(HttpClient kid, LearningAttemptDto attempt)
{
foreach (var item in attempt.Items)
{
var value = item.AnswerType switch
{
LearningAnswerType.Number => "4.711,5",
LearningAnswerType.SingleChoice => "0",
_ => SecretText,
};
await SaveAsync(kid, attempt.Id, item.ItemId, value);
}
return await SubmitAsync(kid, attempt.Id);
}
}
@@ -0,0 +1,554 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Channels;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Dashboard;
using FamilyPlanner.Shared.Learning;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
using static FamilyPlanner.IntegrationTests.LearningSupport;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Lernaufgaben end-to-end über den BFF (ADR-0021): Rechte je Rolle (404 für fremde Kinder), Blätter-CRUD, Auto-Bewertung, Übung vs. Prüfung,
/// Lösungen-Leck, Wiederholungen, Bearbeitungssperre, Live-Ereignisse, Dashboard. Punkte: <see cref="LearningPointsTests"/>, Zeitlimit: <see cref="LearningTimeLimitTests"/>.
/// </summary>
public sealed class LearningTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Permissions_are_enforced_per_role_and_foreign_sheets_are_hidden()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, sibling) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, otherParent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var admin = await fixture.CreateAdminClientAsync(Ct);
var draft = await CreateAsync(parent, NewSet(child.Id), assign: false);
var set = await CreateAsync(parent, NewSet(child.Id));
var attempt = await StartAsync(kid, set.Set.Id);
// Gast: keinerlei Zugriff
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/learning/sets", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/learning/my", Ct)).StatusCode);
// Kind: keine Verwaltung, keine Prüfung, keine Bestätigung
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync("/api/learning/sets", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsJsonAsync("/api/learning/sets", NewSet(child.Id), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync("/api/learning/review", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/learning/attempts/{attempt.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsJsonAsync($"/api/learning/attempts/{attempt.Id}/confirm", new ConfirmAttemptRequest(), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await kid.DeleteAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
// Geschwisterkind: alles 404, auch Starten/Speichern/Abgeben; die Liste zeigt nichts
Assert.Empty(await MineAsync(sibling));
Assert.Equal(HttpStatusCode.NotFound, (await sibling.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await sibling.GetAsync($"/api/learning/my/attempts/{attempt.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await SaveRawAsync(sibling, attempt.Id, attempt.Items[0].ItemId, "1")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await sibling.PostAsync($"/api/learning/my/attempts/{attempt.Id}/submit", null, Ct)).StatusCode);
// Eltern lösen nicht für Kinder: auch für sie ist der fremde Versuch nicht da
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/my/attempts/{attempt.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await parent.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
Assert.Empty(await MineAsync(parent));
// Entwürfe sind für das Zielkind unsichtbar
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == draft.Set.Id);
Assert.Equal(HttpStatusCode.NotFound, (await kid.PostAsync($"/api/learning/my/{draft.Set.Id}/attempts", null, Ct)).StatusCode);
// Eltern und Admin verwalten alles (auch Blätter anderer Eltern)
foreach (var client in new[] { parent, otherParent, admin })
{
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
}
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/sets/{Guid.NewGuid()}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/attempts/{Guid.NewGuid()}", Ct)).StatusCode);
// Der Versuch läuft noch: für Eltern erst nach der Abgabe prüfbar
Assert.Equal(HttpStatusCode.Conflict, (await parent.GetAsync($"/api/learning/attempts/{attempt.Id}", Ct)).StatusCode);
// Ohne Anmeldung: 401
using var anonymous = fixture.CreateApiClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/learning/my", Ct)).StatusCode);
}
[Fact]
public async Task Sheets_are_created_validated_versioned_assigned_and_edited()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (guestUser, _) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (parentUser, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
// Validierung (serverseitig verbindlich)
async Task<HttpStatusCode> PostAsync(CreateLearningSetRequest request) => (await parent.PostAsJsonAsync("/api/learning/sets", request, Ct)).StatusCode;
var valid = NewSet(child.Id);
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Title = " " }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Subject = "Sport" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { GradeLevel = 7 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { MaxAttempts = 21 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TimeLimitMinutes = 0 }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { GradeThresholds = "90;95;70;50;30" }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = parentUser.Id })); // kein Kind
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = guestUser.Id })); // kein Kind
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = Guid.NewGuid() })); // unbekannt
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Foto machen", LearningAnswerType.Paper, null)] })); // Paper erst in 10c
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Zahl?", LearningAnswerType.Number, "keine Zahl")] }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["nur eine"])] }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Wahl?", LearningAnswerType.SingleChoice, "5", Options: ["a", "b"])] }));
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Punkte?", LearningAnswerType.Text, "x", Points: 99)] }));
// Anlegen als Entwurf (Notenschlüssel normalisiert, Standard → null)
var created = await CreateAsync(parent, valid with { Mode = LearningMode.Exam, MaxAttempts = 1, TimeLimitMinutes = 20, GradeThresholds = "92; 81; 67; 50; 30" }, assign: false);
Assert.Equal(LearningSetStatus.Draft, created.Set.Status);
Assert.Null(created.Set.GradeThresholds);
Assert.Equal(LearningSource.Manual, created.Set.Source);
Assert.Equal(3, created.Items.Count);
Assert.Equal([0, 1, 2], created.Items.Select(i => i.SortOrder));
Assert.NotEmpty(created.Set.Version);
Assert.Equal(SecretNumber, created.Items[0].CorrectAnswer); // Eltern sehen die Lösung
// Ändern: Version prüfen (409 mit aktuellem Stand), Ziel muss ein Kind sein
var update = UpdateLearningSetRequest.From(created.Set) with { Title = "Neuer Titel", GradeThresholds = "90;80;70;60;50" };
var updated = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", update, Ct);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var current = (await updated.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
Assert.Equal(("Neuer Titel", "90;80;70;60;50"), (current.Set.Title, current.Set.GradeThresholds));
var stale = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", update with { Title = "Veraltet" }, Ct);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.Equal("Neuer Titel", (await stale.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Set.Title);
Assert.Equal(HttpStatusCode.BadRequest,
(await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", UpdateLearningSetRequest.From(current.Set) with { TargetUserId = parentUser.Id }, Ct)).StatusCode);
// Aufgaben: anhängen, ändern (Version), umsortieren, löschen – das Blatt liefert jeweils den neuen Stand samt Version
var added = await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items", new LearningItemInput("Vierte Frage", LearningAnswerType.Text, "Antwort"), Ct);
Assert.Equal(HttpStatusCode.OK, added.StatusCode);
var withFour = (await added.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
Assert.Equal(4, withFour.Items.Count);
Assert.Equal(3, withFour.Items[3].SortOrder);
Assert.NotEqual(current.Set.Version, withFour.Set.Version);
var fourth = withFour.Items[3];
var edit = UpdateLearningItemRequest.From(fourth) with { Question = "Vierte Frage (neu)" };
var edited = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit, Ct);
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
var staleItem = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit with { Question = "Veraltet" }, Ct);
Assert.Equal(HttpStatusCode.Conflict, staleItem.StatusCode);
Assert.Equal("Vierte Frage (neu)", (await staleItem.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Items[3].Question);
Assert.Equal(HttpStatusCode.BadRequest,
(await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit with { AnswerType = LearningAnswerType.Paper, Version = null }, Ct)).StatusCode);
var ids = withFour.Items.Select(i => i.Id).ToList();
var reordered = await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/order", new ReorderLearningItemsRequest([ids[3], ids[0], ids[1], ids[2]]), Ct);
Assert.Equal([ids[3], ids[0], ids[1], ids[2]], (await reordered.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Items.Select(i => i.Id));
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/order", new ReorderLearningItemsRequest([ids[0]]), Ct)).StatusCode);
var deleted = await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}/items/{ids[3]}", Ct);
var afterDelete = (await deleted.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
Assert.Equal([ids[0], ids[1], ids[2]], afterDelete.Items.Select(i => i.Id));
Assert.Equal([0, 1, 2], afterDelete.Items.Select(i => i.SortOrder));
// Zuweisen braucht Aufgaben; Entwürfe zurücknehmen geht nur ohne Versuch
var empty = await CreateAsync(parent, NewSet(child.Id) with { Items = [] }, assign: false);
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{empty.Set.Id}/assign", null, Ct)).StatusCode);
var assigned = await AssignAsync(parent, created.Set.Id);
Assert.Equal((LearningSetStatus.Assigned, true), (assigned.Set.Status, assigned.Set.AssignedAt is not null));
Assert.Contains(await MineAsync(kid), s => s.Id == created.Set.Id);
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{created.Set.Id}/assign", null, Ct)).StatusCode); // schon zugewiesen
var back = await parent.PostAsync($"/api/learning/sets/{created.Set.Id}/unassign", null, Ct);
Assert.Equal(LearningSetStatus.Draft, (await back.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Set.Status);
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == created.Set.Id);
// Liste, Löschen (idempotent, Soft Delete)
var list = (await parent.GetFromJsonAsync<List<LearningSetDto>>("/api/learning/sets", Ct))!;
Assert.Equal(3, list.Single(s => s.Id == created.Set.Id).ItemCount);
Assert.Equal(HttpStatusCode.NoContent, (await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NoContent, (await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
Assert.DoesNotContain(await parent.GetFromJsonAsync<List<LearningSetDto>>("/api/learning/sets", Ct) ?? [], s => s.Id == created.Set.Id);
}
[Fact]
public async Task Practice_gives_instant_feedback_grades_automatically_and_sends_free_text_to_the_review()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { ShowSolutionsAfterSubmit = true });
var mine = Assert.Single(await MineAsync(kid));
Assert.Equal((set.Set.Id, 3, true, 0), (mine.Id, mine.ItemCount, mine.CanStart, mine.AttemptsUsed));
var attempt = await StartAsync(kid, set.Set.Id);
Assert.Equal((LearningAttemptStatus.InProgress, LearningMode.Practice, 1), (attempt.Status, attempt.Mode, attempt.Number));
Assert.Null(attempt.DeadlineAt);
Assert.Equal(3, attempt.Items.Count);
Assert.Equal(SecretHint, attempt.Items[0].Hint); // Übung: Hinweise ja
Assert.All(attempt.Items, i => Assert.Null(i.Feedback)); // noch nichts beantwortet
// Start ist idempotent: ein laufender Versuch wird fortgesetzt (kein zweiter, kein Verbrauch)
Assert.Equal(attempt.Id, (await StartAsync(kid, set.Set.Id)).Id);
var number = attempt.Items[0].ItemId;
var choice = attempt.Items[1].ItemId;
var text = attempt.Items[2].ItemId;
// Sofort-Rückmeldung: nur richtig/falsch/offen, nie die Lösung; Zahlenformate mit Tausenderpunkt und Komma
var first = await SaveAsync(kid, attempt.Id, number, "4.711,5");
Assert.Equal((true, LearningVerdict.Correct), (first.Answered, first.Verdict));
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, number, "4711.5")).Verdict);
Assert.Equal(LearningVerdict.Wrong, (await SaveAsync(kid, attempt.Id, choice, "1")).Verdict);
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, choice, "0")).Verdict);
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, choice, "0")).Verdict); // idempotent
Assert.Equal(LearningVerdict.Open, (await SaveAsync(kid, attempt.Id, text, " dackel ")).Verdict); // Freitext: Eltern prüfen
var cleared = await SaveAsync(kid, attempt.Id, text, " ");
Assert.Equal((false, (LearningVerdict?)null), (cleared.Answered, cleared.Verdict));
Assert.Equal(LearningVerdict.Open, (await SaveAsync(kid, attempt.Id, text, "Dackel")).Verdict);
// Fremde/unbekannte Aufgaben-Id
Assert.Equal(HttpStatusCode.NotFound, (await SaveRawAsync(kid, attempt.Id, Guid.NewGuid(), "1")).StatusCode);
// Während der Bearbeitung nie die Lösung
var running = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
AssertNoSolutions(running);
// Abgabe bewertet alles fertig; Lösungen nach Abgabe sichtbar (eingestellt)
var submitted = await SubmitAsync(kid, attempt.Id);
Assert.Equal(LearningAttemptStatus.Submitted, submitted.Status);
Assert.Equal(1, submitted.Result!.OpenCount);
Assert.Equal((3, false), (submitted.Result.EarnedPoints, submitted.Result.IsFinal)); // 2 + 1, Freitext offen
Assert.Equal(LearningVerdict.Open, submitted.Items[2].Feedback!.Verdict);
Assert.Equal(SecretNumber, submitted.Items[0].Feedback!.CorrectAnswer);
Assert.Equal(SecretExplanation, submitted.Items[0].Feedback!.Explanation);
var again = await SubmitAsync(kid, attempt.Id); // idempotent
Assert.Equal((LearningAttemptStatus.Submitted, 3), (again.Status, again.Result!.EarnedPoints));
// Danach sind Änderungen nicht mehr möglich (409)
Assert.Equal(HttpStatusCode.Conflict, (await SaveRawAsync(kid, attempt.Id, number, "1")).StatusCode);
// Prüf-Warteschlange: Bestätigen geht erst, wenn alles bewertet ist
var queued = Assert.Single(await QueueAsync(parent), q => q.AttemptId == attempt.Id);
Assert.Equal((1, child.Id, 3, 4), (queued.OpenCount, queued.ChildId, queued.EarnedPoints, queued.TotalItemPoints));
var review = await GetReviewAsync(parent, attempt.Id);
Assert.Equal(SecretText, review.Answers[2].CorrectAnswer);
Assert.Equal("Dackel", review.Answers[2].Answer);
Assert.Equal(LearningGrader.Auto, review.Answers[0].Grader);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PostAsJsonAsync($"/api/learning/attempts/{attempt.Id}/confirm", new ConfirmAttemptRequest(), Ct)).StatusCode);
// Freitext korrigieren: „teilweise“ geht bei 1 Punkt nicht (400), „richtig“ schon
Assert.Equal(HttpStatusCode.BadRequest,
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Partial, 1, null), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Open), Ct)).StatusCode);
var reviewed = await ReviewAsync(parent, attempt.Id, text, LearningVerdict.Correct);
var parentAnswer = reviewed.Answers[2];
Assert.Equal((LearningVerdict.Correct, LearningGrader.Parent, 1, "Geprüft"), (parentAnswer.Verdict, parentAnswer.Grader, parentAnswer.AwardedPoints, parentAnswer.Reason));
Assert.Equal((4, 0), (reviewed.EarnedPoints, reviewed.OpenCount));
var confirmed = await ConfirmAsync(parent, attempt.Id);
Assert.Equal(LearningAttemptStatus.Reviewed, confirmed.Attempt.Status);
Assert.DoesNotContain(await QueueAsync(parent), x => x.AttemptId == attempt.Id);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Wrong), Ct)).StatusCode);
var final = await GetAttemptAsync(kid, attempt.Id);
Assert.Equal((true, 4, 4, 0), (final.Result!.IsFinal, final.Result.EarnedPoints, final.Result.TotalItemPoints, final.Result.OpenCount));
Assert.Null(final.Result.Grade); // Übung: keine Note
Assert.Equal(LearningSetStatus.Reviewed, Assert.Single(await MineAsync(kid)).Status);
}
[Fact]
public async Task Exam_hides_feedback_hints_and_solutions_until_submitted_and_grades_with_the_scale()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, ShowSolutionsAfterSubmit = false, MaxAttempts = 1 });
var attempt = await StartAsync(kid, set.Set.Id);
Assert.Equal(LearningMode.Exam, attempt.Mode);
Assert.All(attempt.Items, i => Assert.Null(i.Hint));
Assert.All(attempt.Items, i => Assert.Null(i.Feedback));
// Keine Sofort-Rückmeldung: die Antwort des Servers enthält keine Bewertung
var number = attempt.Items[0].ItemId;
var saved = await SaveAsync(kid, attempt.Id, number, "4711,50");
Assert.Equal((true, (LearningVerdict?)null), (saved.Answered, saved.Verdict));
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, "2"); // falsch
await SaveAsync(kid, attempt.Id, attempt.Items[2].ItemId, " DACKELWURST."); // exakt (normalisiert) → automatisch richtig
var running = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
AssertNoSolutions(running);
AssertNoHints(running);
AssertNoFeedback(running);
// Abgegeben: Bewertung sichtbar, aber keine Lösungen/Hinweise (Einstellung „Lösungen zeigen“ aus)
var submitted = await SubmitAsync(kid, attempt.Id);
Assert.Equal([LearningVerdict.Correct, LearningVerdict.Wrong, LearningVerdict.Correct], submitted.Items.Select(i => i.Feedback!.Verdict));
Assert.All(submitted.Items, i => Assert.Null(i.Hint));
Assert.All(submitted.Items, i => Assert.Null(i.Feedback!.CorrectAnswer));
Assert.All(submitted.Items, i => Assert.Null(i.Feedback!.Explanation));
var afterSubmit = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
AssertNoSolutions(afterSubmit);
AssertNoHints(afterSubmit);
Assert.Null(submitted.Result!.Grade); // erst nach der Elternbestätigung
Assert.Equal(3, submitted.Result.EarnedPoints);
// Bestätigen: Note nach Standardschlüssel (3 von 4 = 75 % → Note 3)
var parentView = await GetReviewAsync(parent, attempt.Id);
Assert.Equal(3, parentView.Grade); // Vorschau, da nichts offen ist
var confirmed = await ConfirmAsync(parent, attempt.Id);
Assert.Equal(3, confirmed.Attempt.Grade);
Assert.Equal(3, (await GetAttemptAsync(kid, attempt.Id)).Result!.Grade);
// Einmalige Prüfung: kein zweiter Versuch
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
var mine = Assert.Single(await MineAsync(kid));
Assert.Equal((false, 1), (mine.CanStart, mine.AttemptsUsed));
Assert.Equal(3, mine.Results[0].Grade);
}
[Fact]
public async Task Solutions_are_shown_after_submission_only_when_enabled_and_never_before()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, ShowSolutionsAfterSubmit = true });
var attempt = await StartAsync(kid, set.Set.Id);
var before = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
AssertNoSolutions(before);
AssertNoHints(before);
AssertNoFeedback(before);
foreach (var item in attempt.Items)
{
await SaveAsync(kid, attempt.Id, item.ItemId, "x");
}
var submitted = await SubmitAsync(kid, attempt.Id);
Assert.Equal(SecretNumber, submitted.Items[0].Feedback!.CorrectAnswer);
Assert.Equal(SecretExplanation, submitted.Items[0].Feedback!.Explanation);
Assert.Equal("0", submitted.Items[1].Feedback!.CorrectAnswer);
Assert.Equal(SecretText, submitted.Items[2].Feedback!.CorrectAnswer);
Assert.All(submitted.Items, i => Assert.Null(i.Hint)); // Hinweise gibt es nur in der Übung
}
[Fact]
public async Task Attempts_are_limited_and_sets_are_locked_after_the_first_attempt()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { MaxAttempts = 2 });
var first = await StartAsync(kid, set.Set.Id);
Assert.Equal(HttpStatusCode.OK, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode); // läuft → fortgesetzt
// Nach dem Start: Aufgaben und feste Einstellungen gesperrt, Metadaten änderbar
var detail = await GetSetAsync(parent, set.Set.Id);
Assert.Equal(LearningSetStatus.InProgress, detail.Set.Status);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PostAsJsonAsync($"/api/learning/sets/{set.Set.Id}/items", new LearningItemInput("Neu", LearningAnswerType.Text, "x"), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}/items/{detail.Items[0].Id}", UpdateLearningItemRequest.From(detail.Items[0]) with { Points = 5 }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await parent.DeleteAsync($"/api/learning/sets/{set.Set.Id}/items/{detail.Items[0].Id}", Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{set.Set.Id}/unassign", null, Ct)).StatusCode);
var update = UpdateLearningSetRequest.From(detail.Set);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { Mode = LearningMode.Exam }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { TotalPoints = 50 }, Ct)).StatusCode);
Assert.Equal(HttpStatusCode.OK,
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { Title = "Umbenannt", MaxAttempts = 3 }, Ct)).StatusCode);
// Zwei Versuche waren erlaubt; mit „3“ nach dem Umstellen gibt es einen weiteren
await SubmitAsync(kid, first.Id);
var second = await StartAsync(kid, set.Set.Id);
Assert.Equal(2, second.Number);
await SubmitAsync(kid, second.Id);
var third = await StartAsync(kid, set.Set.Id);
Assert.Equal(3, third.Number);
await SubmitAsync(kid, third.Id);
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
var mine = Assert.Single(await MineAsync(kid));
Assert.Equal((3, false), (mine.AttemptsUsed, mine.CanStart));
Assert.Equal([3, 2, 1], mine.Results.Select(r => r.Number));
}
[Fact]
public async Task Shuffled_order_is_stable_per_attempt()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var items = Enumerable.Range(1, 10).Select(i => new LearningItemInput($"Frage {i}", LearningAnswerType.Text, $"A{i}")).ToList();
var set = await CreateAsync(parent, NewSet(child.Id) with { ShuffleItems = true, Items = items, MaxAttempts = 0 });
var attempt = await StartAsync(kid, set.Set.Id);
var order = attempt.Items.Select(i => i.ItemId).ToList();
Assert.Equal(set.Items.Select(i => i.Id).Order(), order.Order());
Assert.Equal(Enumerable.Range(0, 10), attempt.Items.Select(i => i.Position));
Assert.Equal(order, (await GetAttemptAsync(kid, attempt.Id)).Items.Select(i => i.ItemId));
Assert.Equal(order, (await StartAsync(kid, set.Set.Id)).Items.Select(i => i.ItemId));
await SubmitAsync(kid, attempt.Id);
Assert.Equal(order, (await GetAttemptAsync(kid, attempt.Id)).Items.Select(i => i.ItemId)); // auch nach der Abgabe
}
[Fact]
public async Task Live_events_reach_only_the_child_and_parents()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (sibling, _) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (guest, _) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (mom, momClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (dad, _) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
await using var kidHub = await ListenAsync(child.UserName);
await using var siblingHub = await ListenAsync(sibling.UserName);
await using var guestHub = await ListenAsync(guest.UserName);
await using var momHub = await ListenAsync(mom.UserName);
await using var dadHub = await ListenAsync(dad.UserName);
var set = await CreateAsync(momClient, NewSet(child.Id));
var assigned = await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
Assert.Equal(LearningChangeKind.Assigned, assigned.Kind);
await ReadAsync(momHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
await ReadAsync(dadHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
var attempt = await StartAsync(kid, set.Set.Id);
await SolveAllCorrectlyAsync(kid, attempt);
await ReadAsync(momHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
await ReadAsync(dadHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
await ConfirmAsync(momClient, attempt.Id);
await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Reviewed);
// Geschwister und Gäste erfahren nichts (die Ereignisse oben waren bereits zugestellt)
Assert.False(siblingHub.Events.Reader.TryRead(out _));
Assert.False(guestHub.Events.Reader.TryRead(out _));
}
[Fact]
public async Task Dashboard_tile_shows_open_sheets_to_children_and_the_review_queue_to_parents()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
Assert.Null((await Dashboard(guest)).Learning);
var emptyChild = (await Dashboard(kid)).Learning!;
Assert.Equal((0, 0, false), (emptyChild.OpenCount, emptyChild.ToReviewCount, emptyChild.ForParents));
Assert.True((await Dashboard(parent)).Learning!.ForParents);
var set = await CreateAsync(parent, NewSet(child.Id) with { DueDate = new DateOnly(2000, 1, 1) });
var tile = (await Dashboard(kid)).Learning!;
Assert.Equal(1, tile.OpenCount);
var item = Assert.Single(tile.Items);
Assert.Equal((set.Set.Id, set.Set.Title, true), (item.SetId, item.Title, item.IsOverdue));
Assert.True((await Dashboard(parent)).Learning!.OpenCount >= 1); // familienweit: andere Tests laufen parallel
var attempt = await StartAsync(kid, set.Set.Id);
Assert.Equal(attempt.Id, Assert.Single((await Dashboard(kid)).Learning!.Items).AttemptId);
await SolveAllCorrectlyAsync(kid, attempt);
Assert.Equal(0, (await Dashboard(kid)).Learning!.OpenCount); // abgegeben = nicht mehr offen
var parentTile = (await Dashboard(parent)).Learning!;
Assert.True(parentTile.ToReviewCount >= 1);
Assert.NotEmpty(parentTile.Items);
Assert.All(parentTile.Items, i => Assert.NotNull(i.AttemptId));
}
// ---------- Hilfen ----------
private static async Task<DashboardDto> Dashboard(HttpClient client) => (await client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!;
/// <summary>Keine Musterlösung und Erklärung in der Antwort (Struktur und Marker).</summary>
private static void AssertNoSolutions(string json)
{
Assert.DoesNotContain(SecretExplanation, json);
using var document = JsonDocument.Parse(json);
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
{
var feedback = item.GetProperty("feedback");
if (feedback.ValueKind == JsonValueKind.Object)
{
Assert.Equal(JsonValueKind.Null, feedback.GetProperty("correctAnswer").ValueKind);
Assert.Equal(JsonValueKind.Null, feedback.GetProperty("explanation").ValueKind);
}
}
}
private static void AssertNoHints(string json)
{
Assert.DoesNotContain(SecretHint, json);
using var document = JsonDocument.Parse(json);
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
{
Assert.Equal(JsonValueKind.Null, item.GetProperty("hint").ValueKind);
}
}
private static void AssertNoFeedback(string json)
{
using var document = JsonDocument.Parse(json);
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
{
Assert.Equal(JsonValueKind.Null, item.GetProperty("feedback").ValueKind);
}
}
private async Task<Listener> ListenAsync(string userName)
{
var cookies = new System.Net.CookieContainer();
using var login = fixture.CreateApiClient(cookies);
await AppFixture.LoginAsync(login, userName, AppFixture.UserPassword, Ct);
var listener = new Listener
{
Connection = new HubConnectionBuilder()
.WithUrl(new Uri(fixture.WebBaseAddress, "/hubs/live"), options =>
{
options.Cookies = cookies;
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
options.HttpMessageHandlerFactory = _ => new HttpClientHandler
{
CookieContainer = cookies,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
options.WebSocketConfiguration = ws => ws.RemoteCertificateValidationCallback = (_, _, _, _) => true;
})
.Build(),
};
listener.Connection.On<LearningChangedDto>(LearningEvents.Changed, e => listener.Events.Writer.TryWrite(e));
await listener.Connection.StartAsync(Ct);
return listener;
}
private sealed class Listener : IAsyncDisposable
{
public HubConnection Connection { get; set; } = null!;
public Channel<LearningChangedDto> Events { get; } = Channel.CreateUnbounded<LearningChangedDto>();
public ValueTask DisposeAsync() => Connection.DisposeAsync();
}
private static async Task<T> ReadAsync<T>(ChannelReader<T> reader, Func<T, bool> predicate)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
while (true)
{
var value = await reader.ReadAsync(timeout.Token);
if (predicate(value))
{
return value;
}
}
}
}
@@ -0,0 +1,134 @@
using System.Net;
using System.Net.Http.Json;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Dashboard;
using FamilyPlanner.Shared.Learning;
using static FamilyPlanner.IntegrationTests.LearningSupport;
namespace FamilyPlanner.IntegrationTests;
/// <summary>
/// Serverseitiges Zeitlimit (ADR-0021 Punkt 12). Der Server hält Start und Ablauf fest; Speicherungen nach Ablauf (plus feste Toleranz) werden mit 409
/// abgelehnt, der Versuch wird automatisch abgegeben. Die Tests warten echte Zeit ab (kleinstes Limit: 1 Minute) und stehen daher in einer eigenen Klasse,
/// die parallel zu den übrigen Lernaufgaben-Tests läuft.
/// </summary>
public sealed class LearningTimeLimitTests(AppFixture fixture)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static async Task WaitUntilAsync(DateTimeOffset moment)
{
var delay = moment - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, Ct);
}
}
[Fact]
public async Task Late_saves_are_rejected_with_409_and_the_attempt_is_submitted_automatically()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 1 });
var attempt = await StartAsync(kid, set.Set.Id);
// Start und Ablauf legt der Server fest (genau das Limit), Client-Uhr ist nicht maßgeblich
Assert.NotNull(attempt.DeadlineAt);
Assert.Equal(TimeSpan.FromMinutes(1), attempt.DeadlineAt!.Value - attempt.StartedAt);
Assert.InRange((attempt.ServerNow - DateTimeOffset.UtcNow).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(30));
Assert.Equal(attempt.DeadlineAt, (await GetAttemptAsync(kid, attempt.Id)).DeadlineAt); // stabil, kein Zurücksetzen der Uhr
Assert.Equal(attempt.DeadlineAt, (await StartAsync(kid, set.Set.Id)).DeadlineAt); // Fortsetzen startet die Uhr nicht neu
// Innerhalb der Zeit wird gespeichert und bewertet
var saved = await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, "4711,5");
Assert.Equal(attempt.DeadlineAt, saved.DeadlineAt);
// Nach Ablauf plus Toleranz: 409 – die Antwort zählt nicht
await WaitUntilAsync(attempt.DeadlineAt.Value + LearningRules.DeadlineTolerance + TimeSpan.FromSeconds(2));
var late = await SaveRawAsync(kid, attempt.Id, attempt.Items[1].ItemId, "0");
Assert.Equal(HttpStatusCode.Conflict, late.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await SaveRawAsync(kid, attempt.Id, attempt.Items[0].ItemId, "1")).StatusCode);
// Automatisch abgegeben (Zeitpunkt = Ablauf), nur bis dahin gespeicherte Antworten sind gewertet
var expired = await GetAttemptAsync(kid, attempt.Id);
Assert.Equal((LearningAttemptStatus.Submitted, true), (expired.Status, expired.AutoSubmitted));
Assert.Equal(attempt.DeadlineAt, expired.Result!.SubmittedAt);
Assert.Equal(2, expired.Result.EarnedPoints); // nur die rechtzeitige Zahl (2 Punkte)
Assert.Equal(LearningVerdict.Correct, expired.Items[0].Feedback!.Verdict);
Assert.Equal(LearningVerdict.Wrong, expired.Items[1].Feedback!.Verdict); // nie beantwortet
Assert.Equal("4711,5", expired.Items[0].Answer);
Assert.Null(expired.Items[1].Answer);
// Abgegebener Versuch erscheint in der Prüf-Warteschlange (Eltern); die Abgabe ist idempotent
var queued = Assert.Single(await QueueAsync(parent), q => q.AttemptId == attempt.Id);
Assert.True(queued.AutoSubmitted);
var submitAgain = await SubmitAsync(kid, attempt.Id);
Assert.True(submitAgain.AutoSubmitted);
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode); // einmalig: verbraucht
var review = await GetReviewAsync(parent, attempt.Id);
Assert.True(review.AutoSubmitted);
Assert.Equal(2, review.EarnedPoints);
}
[Fact]
public async Task Abandoned_exams_are_finalized_for_the_review_queue_without_any_child_request()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 2 });
var attempt = await StartAsync(kid, set.Set.Id);
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, "0");
// Das Kind schließt die App – der Hintergrunddienst bzw. der Zugriff der Eltern gibt den Versuch nach Ablauf ab.
await WaitUntilAsync(attempt.DeadlineAt!.Value + LearningRules.DeadlineTolerance + TimeSpan.FromSeconds(2));
var queued = await QueueAsync(parent);
Assert.Contains(queued, q => q.AttemptId == attempt.Id && q.AutoSubmitted);
// Ein neuer Versuch ist erlaubt (2 Versuche), der erste zählt als verbraucht
var next = await StartAsync(kid, set.Set.Id);
Assert.Equal(2, next.Number);
Assert.Equal(LearningAttemptStatus.Submitted, (await GetAttemptAsync(kid, attempt.Id)).Status);
}
/// <summary>
/// Beweist den Hintergrunddienst <c>LearningExpiryService</c>: Nach dem Start macht das Kind <b>keinen</b> Lern-Aufruf mehr (jeder davon würde den
/// abgelaufenen Versuch lazy abgeben). Beobachtet wird nur die Dashboard-Kachel des Kindes, die selbst nichts abgibt – ihre Zahl offener
/// Blätter sinkt nur, wenn der Dienst den Versuch tatsächlich abgegeben hat. Ein frisches Kind macht den Zähler privat (parallele Tests stören nicht).
/// </summary>
[Fact]
public async Task The_background_service_submits_expired_attempts_without_any_learning_request()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 1 });
var attempt = await StartAsync(kid, set.Set.Id);
await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, "4711,5");
var tile = (await kid.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Learning!;
Assert.Equal(1, tile.OpenCount); // läuft noch
// Ab hier nur noch das Dashboard des Kindes abfragen – kein /api/learning-Aufruf, der lazy abgeben könnte.
var deadline = attempt.DeadlineAt!.Value + LearningRules.DeadlineTolerance;
var give = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(3);
var open = 1;
while (open != 0 && DateTimeOffset.UtcNow < give)
{
await Task.Delay(TimeSpan.FromSeconds(3), Ct);
open = (await kid.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!.Learning!.OpenCount;
}
Assert.True(open == 0, "Der Hintergrunddienst hat den abgelaufenen Versuch nicht abgegeben.");
Assert.True(DateTimeOffset.UtcNow > deadline, "Der Versuch darf erst nach Frist plus Toleranz abgegeben werden.");
// Ergebnis prüfen (jetzt dürfen Lern-Aufrufe kommen): automatisch abgegeben, rechtzeitig gespeicherte Antwort zählt
var submitted = await GetAttemptAsync(kid, attempt.Id);
Assert.Equal((LearningAttemptStatus.Submitted, true), (submitted.Status, submitted.AutoSubmitted));
Assert.Equal(attempt.DeadlineAt, submitted.Result!.SubmittedAt);
Assert.Equal(2, submitted.Result.EarnedPoints);
}
}
@@ -29,6 +29,8 @@ public sealed class PermissionMatrixTests
{ Permission.FamilyChat, F, F, F, N }, // Gast: nur wenn freigegeben
{ Permission.DirectMessages, F, F, R, N }, // Kind: nur an Familienmitglieder
{ Permission.AiUse, F, F, N, N }, // KI nur für Eltern (ADR-0020)
{ Permission.LearningManage, F, F, N, N }, // Übungsblätter verwalten und prüfen (ADR-0021)
{ Permission.LearningSolve, F, F, R, N }, // Kind: nur eigene Blätter lösen (ADR-0021)
{ Permission.UserManagement, F, N, N, N },
{ Permission.CalendarManagement, F, N, N, N },
{ Permission.SettingsManagement, F, N, N, N },
@@ -0,0 +1,104 @@
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Notenschlüssel (Standard: Schulnoten 1–6 nach Prozent) und Prüfung des Schlüssels.</summary>
public sealed class LearningGradesTests
{
[Theory]
[InlineData(100, 100, 1)]
[InlineData(92, 100, 1)]
[InlineData(91, 100, 2)]
[InlineData(81, 100, 2)]
[InlineData(80, 100, 3)]
[InlineData(67, 100, 3)]
[InlineData(66, 100, 4)]
[InlineData(50, 100, 4)]
[InlineData(49, 100, 5)]
[InlineData(30, 100, 5)]
[InlineData(29, 100, 6)]
[InlineData(0, 100, 6)]
public void Default_scale_maps_percent_to_school_grades(int earned, int total, int expected) =>
Assert.Equal(expected, LearningGrades.GradeFor(earned, total));
[Theory]
[InlineData(23, 25, 1)] // 92 % exakt (ohne Gleitkomma-Rundung)
[InlineData(22, 25, 2)] // 88 %
[InlineData(3, 4, 3)] // 75 %
[InlineData(2, 4, 4)] // 50 % exakt
[InlineData(1, 3, 5)] // 33 %
[InlineData(2, 3, 4)] // 66,7 % liegt knapp unter 67 %
[InlineData(0, 3, 6)]
[InlineData(1, 1, 1)]
public void Grades_use_exact_integer_comparison(int earned, int total, int expected) =>
Assert.Equal(expected, LearningGrades.GradeFor(earned, total));
[Fact]
public void A_custom_scale_overrides_the_default()
{
Assert.True(LearningGrades.TryParse("90;80;70;60;50", out var thresholds, out var error));
Assert.Null(error);
Assert.Equal(1, LearningGrades.GradeFor(90, 100, thresholds));
Assert.Equal(2, LearningGrades.GradeFor(89, 100, thresholds));
Assert.Equal(5, LearningGrades.GradeFor(50, 100, thresholds));
Assert.Equal(6, LearningGrades.GradeFor(49, 100, thresholds));
Assert.Equal(1, LearningGrades.GradeFor(100, 100, [100, 80, 60, 40, 20])); // nur volle Punktzahl ergibt 1
Assert.Equal(2, LearningGrades.GradeFor(99, 100, [100, 80, 60, 40, 20]));
}
[Fact]
public void Zero_total_is_the_worst_grade()
{
Assert.Equal(6, LearningGrades.GradeFor(0, 0));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_scale_means_default(string? text)
{
Assert.True(LearningGrades.TryParse(text, out var thresholds, out var error));
Assert.Null(error);
Assert.Equal(LearningGrades.DefaultThresholds, thresholds);
}
[Theory]
[InlineData("92;81;67;50;30", true)]
[InlineData("92; 81; 67; 50; 30", true)]
[InlineData("92,81,67,50,30", true)]
[InlineData("100;80;60;40;20", true)]
[InlineData("5;4;3;2;1", true)]
[InlineData("92;81;67;50", false)] // zu wenige
[InlineData("92;81;67;50;30;10", false)] // zu viele
[InlineData("92;81;67;50;abc", false)]
[InlineData("92;81;81;50;30", false)] // nicht streng absteigend
[InlineData("30;50;67;81;92", false)] // aufsteigend
[InlineData("101;81;67;50;30", false)] // über 100
[InlineData("92;81;67;50;0", false)] // 0 ist nicht sinnvoll
[InlineData("-5;81;67;50;30", false)]
public void Scales_are_validated(string text, bool valid)
{
Assert.Equal(valid, LearningGrades.TryParse(text, out _, out var error));
Assert.Equal(valid, error is null);
}
[Fact]
public void Format_stores_only_custom_scales()
{
Assert.Null(LearningGrades.Format(LearningGrades.DefaultThresholds));
Assert.Equal("90;80;70;60;50", LearningGrades.Format([90, 80, 70, 60, 50]));
}
[Theory]
[InlineData(1, "sehr gut")]
[InlineData(2, "gut")]
[InlineData(3, "befriedigend")]
[InlineData(4, "ausreichend")]
[InlineData(5, "mangelhaft")]
[InlineData(6, "ungenügend")]
[InlineData(0, "")]
[InlineData(7, "")]
public void Grades_have_german_labels(int grade, string label) => Assert.Equal(label, LearningGrades.Label(grade));
}
@@ -0,0 +1,210 @@
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Zahlenformate und automatische Bewertung (ADR-0021 Punkt 3): Komma/Punkt, Tausendertrenner, Toleranz, Auswahl, Freitext.</summary>
public sealed class LearningGradingTests
{
private static LearningItem Number(string correct, decimal? tolerance = null, int points = 2) =>
new() { AnswerType = LearningAnswerType.Number, CorrectAnswer = correct, NumberTolerance = tolerance, Points = points };
private static LearningItem Text(string correct, int points = 1) =>
new() { AnswerType = LearningAnswerType.Text, CorrectAnswer = correct, Points = points };
private static LearningItem Choice(string correctIndex, params string[] options) =>
new() { AnswerType = LearningAnswerType.SingleChoice, CorrectAnswer = correctIndex, Options = [.. options], Points = 1 };
// ---------- Zahlen lesen ----------
public static TheoryData<string, decimal[]> NumberFormats => new()
{
{ "12", [12m] },
{ " 12 ", [12m] },
{ "-12", [-12m] },
{ "+12", [12m] },
{ "−12", [-12m] }, // typografisches Minus
{ "12,5", [12.5m] },
{ "12.5", [12.5m] },
{ "0,125", [0.125m] },
{ "0.125", [0.125m] },
{ ",5", [0.5m] },
{ "5,", [5m] },
{ "1.234,5", [1234.5m] }, // deutsch
{ "1,234.5", [1234.5m] }, // englisch
{ "1.234.567", [1234567m] },
{ "1,234,567", [1234567m] },
{ "1.234.567,89", [1234567.89m] },
{ "1 234,5", [1234.5m] }, // Leerzeichen als Tausendertrenner
{ "1 234", [1234m] }, // geschütztes Leerzeichen
{ "1.234", [1.234m, 1234m] }, // mehrdeutig: Dezimal oder Tausender → beide Werte
{ "1,234", [1.234m, 1234m] },
{ "12,345", [12.345m, 12345m] },
{ "0,500", [0.5m] }, // führende Null: nur Dezimal
{ "1234,567", [1234.567m] }, // 4 Ziffern vor dem Komma: nur Dezimal
{ "-1.250", [-1.25m, -1250m] },
};
[Theory]
[MemberData(nameof(NumberFormats))]
public void Numbers_are_read_independently_of_the_device_culture(string input, decimal[] expected)
{
using var _ = new CultureScope("de-DE");
Assert.Equal(expected, LearningNumbers.Parse(input));
using var __ = new CultureScope("en-US");
Assert.Equal(expected, LearningNumbers.Parse(input));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("abc")]
[InlineData("1/2")] // Brüche werden bewusst nicht erkannt
[InlineData("5 cm")]
[InlineData("1.2.3")]
[InlineData("1,2,3")]
[InlineData("1,5.3")]
[InlineData("1.234,56,7")]
[InlineData("12e3")]
[InlineData("--5")]
[InlineData("-")]
[InlineData(",")]
[InlineData("12345678901234567890123456789")] // länger als 28 Zeichen
public void Non_numbers_are_not_recognised(string? input) => Assert.Empty(LearningNumbers.Parse(input));
[Fact]
public void ParseFirst_prefers_the_decimal_reading()
{
Assert.Equal(1.234m, LearningNumbers.ParseFirst("1.234"));
Assert.Null(LearningNumbers.ParseFirst("x"));
}
// ---------- Zahlenaufgaben ----------
[Theory]
[InlineData("12,5", "12,5", true)]
[InlineData("12,5", "12.5", true)]
[InlineData("12,5", "12,50", true)]
[InlineData("12,5", " 12,5 ", true)]
[InlineData("12,5", "12", false)]
[InlineData("1234", "1.234", true)] // Tausenderpunkt
[InlineData("1234", "1234", true)]
[InlineData("1234", "1 234", true)]
[InlineData("1,234", "1.234", true)] // mehrdeutige Eingabe passt auf die Lösung
[InlineData("-3", "−3", true)]
[InlineData("0", "0,0", true)]
[InlineData("7", "sieben", false)]
[InlineData("7", "7 cm", false)]
public void Numbers_are_graded_exactly(string correct, string answer, bool expectedCorrect)
{
var result = LearningGrading.Grade(Number(correct, points: 3), answer);
Assert.Equal(expectedCorrect ? LearningVerdict.Correct : LearningVerdict.Wrong, result.Verdict);
Assert.Equal(expectedCorrect ? 3 : 0, result.AwardedPoints);
}
[Theory]
[InlineData("3,14", "0,01", "3,15", true)]
[InlineData("3,14", "0,01", "3,13", true)]
[InlineData("3,14", "0,01", "3,16", false)]
[InlineData("100", "5", "104", true)]
[InlineData("100", "5", "94", false)]
[InlineData("100", "0", "100,1", false)]
public void Tolerance_is_absolute_and_inclusive(string correct, string tolerance, string answer, bool expectedCorrect)
{
var item = Number(correct, decimal.Parse(tolerance, System.Globalization.CultureInfo.GetCultureInfo("de-DE")));
Assert.Equal(expectedCorrect, LearningGrading.Grade(item, answer).Verdict == LearningVerdict.Correct);
}
[Fact]
public void Empty_and_unreadable_answers_are_wrong_never_open()
{
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("1"), null).Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("1"), " ").Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Text("Hund"), "").Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Choice("0", "a", "b"), null).Verdict);
Assert.Equal("Keine Zahl erkannt.", LearningGrading.Grade(Number("1"), "x").Reason);
}
// ---------- Auswahl ----------
[Theory]
[InlineData("1", "1", true)]
[InlineData("1", " 1 ", true)]
[InlineData("1", "0", false)]
[InlineData("1", "2", false)]
[InlineData("1", "abc", false)]
[InlineData("1", "-1", false)]
[InlineData("1", "7", false)] // außerhalb der Optionen
public void Choice_compares_option_indices(string correct, string answer, bool expectedCorrect)
{
var result = LearningGrading.Grade(Choice(correct, "a", "b", "c"), answer);
Assert.Equal(expectedCorrect ? LearningVerdict.Correct : LearningVerdict.Wrong, result.Verdict);
}
[Fact]
public void Choice_with_a_correct_index_outside_the_options_is_never_correct()
{
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Choice("5", "a", "b"), "5").Verdict);
}
// ---------- Freitext ----------
[Theory]
[InlineData("Hund", "hund")]
[InlineData("Hund", " HUND ")]
[InlineData("Hund", "Hund.")]
[InlineData("Hund", "Hund!")]
[InlineData("die Katze", "Die Katze")]
[InlineData("die Katze", "die\tKatze")]
[InlineData("Hund|Wauwau", "wauwau")] // Varianten
[InlineData("Straße", "STRAßE")]
[InlineData("Ärger", "ärger")]
public void Exact_text_matches_after_normalisation_are_correct(string correct, string answer)
{
var result = LearningGrading.Grade(Text(correct, points: 4), answer);
Assert.Equal((LearningVerdict.Correct, 4), (result.Verdict, result.AwardedPoints));
}
[Theory]
[InlineData("Hund", "Hunde")]
[InlineData("Hund", "Hnd")]
[InlineData("Hund", "der Hund")]
[InlineData("Hund|Wauwau", "Katze")]
public void Other_text_is_left_open_for_the_parents_never_wrong(string correct, string answer)
{
var result = LearningGrading.Grade(Text(correct), answer);
Assert.Equal((LearningVerdict.Open, 0), (result.Verdict, result.AwardedPoints));
}
[Fact]
public void Paper_answers_are_open_until_phase_10c()
{
var item = new LearningItem { AnswerType = LearningAnswerType.Paper, Points = 2 };
Assert.Equal(LearningVerdict.Open, LearningGrading.Grade(item, "irgendwas").Verdict);
Assert.False(LearningItemRules.IsSupported(LearningAnswerType.Paper));
}
[Fact]
public void Normalisation_handles_unicode_forms_and_whitespace()
{
Assert.Equal("ärger", LearningGrading.NormalizeText("Ärger")); // zerlegtes Ä
Assert.Equal(string.Empty, LearningGrading.NormalizeText(null));
Assert.Equal(string.Empty, LearningGrading.NormalizeText(" \t "));
Assert.Equal("a b", LearningGrading.NormalizeText(" a \n b "));
}
private sealed class CultureScope : IDisposable
{
private readonly System.Globalization.CultureInfo _previous = System.Globalization.CultureInfo.CurrentCulture;
public CultureScope(string name) => System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(name);
public void Dispose() => System.Globalization.CultureInfo.CurrentCulture = _previous;
}
}
@@ -0,0 +1,202 @@
using System.Text.Json;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>
/// Kinder-DTOs enthalten Musterlösung, Hinweis und Erklärung nur, wenn <see cref="LearningRules.VisibilityFor"/> es erlaubt (ADR-0021 Punkt 5).
/// Geprüft an den serialisierten Antworten – so, wie sie den Server verlassen.
/// </summary>
public sealed class LearningMapperTests
{
private const string Solution = "LOESUNG-4711";
private const string Hint = "HINWEIS-GEHEIM";
private const string Explanation = "ERKLAERUNG-GEHEIM";
private const string ParentReason = "BEGRUENDUNG-DER-ELTERN";
private static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
private static (LearningSet Set, LearningAttempt Attempt) Build(LearningMode mode, bool showSolutions, LearningAttemptStatus status)
{
var set = new LearningSet
{
Title = "Test",
Mode = mode,
ShowSolutionsAfterSubmit = showSolutions,
Status = LearningSetStatus.InProgress,
TargetUserId = Guid.NewGuid(),
TotalPoints = 10,
};
var item1 = new LearningItem { SetId = set.Id, SortOrder = 0, Question = "Frage 1", AnswerType = LearningAnswerType.Number, CorrectAnswer = Solution, Hint = Hint, Explanation = Explanation, Points = 2 };
var item2 = new LearningItem { SetId = set.Id, SortOrder = 1, Question = "Frage 2", AnswerType = LearningAnswerType.SingleChoice, CorrectAnswer = "1", Options = ["a", "b"], Hint = Hint, Explanation = Explanation, Points = 1 };
var item3 = new LearningItem { SetId = set.Id, SortOrder = 2, Question = "Frage 3", AnswerType = LearningAnswerType.Text, CorrectAnswer = Solution, Points = 1 };
set.Items.AddRange([item1, item2, item3]);
var attempt = new LearningAttempt
{
SetId = set.Id,
UserId = set.TargetUserId,
Number = 1,
Status = status,
StartedAt = Now,
ItemOrder = LearningRules.SerializeOrder(set.Items.Select(i => i.Id)),
};
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item1.Id, Value = "7", AnsweredAt = Now, Verdict = LearningVerdict.Wrong, Reason = null });
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item2.Id, Value = "1", AnsweredAt = Now, Verdict = LearningVerdict.Correct, AwardedPoints = 1 });
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item3.Id, Value = "Hallo", AnsweredAt = Now, Verdict = LearningVerdict.Open, Grader = LearningGrader.Auto });
set.Attempts.Add(attempt);
return (set, attempt);
}
private static string Json(LearningSet set, LearningAttempt attempt) =>
JsonSerializer.Serialize(LearningMapper.ToAttemptDto(attempt, set, Now), Web);
public static TheoryData<LearningMode, bool, LearningAttemptStatus, bool, bool> Cases => new()
{
// Modus, Lösungen nach Abgabe, Status, Hinweis sichtbar, Lösung sichtbar
{ LearningMode.Practice, true, LearningAttemptStatus.InProgress, true, false },
{ LearningMode.Practice, false, LearningAttemptStatus.InProgress, true, false },
{ LearningMode.Practice, true, LearningAttemptStatus.Submitted, true, true },
{ LearningMode.Practice, false, LearningAttemptStatus.Submitted, true, false },
{ LearningMode.Practice, true, LearningAttemptStatus.Reviewed, true, true },
{ LearningMode.Exam, true, LearningAttemptStatus.InProgress, false, false },
{ LearningMode.Exam, false, LearningAttemptStatus.InProgress, false, false },
{ LearningMode.Exam, true, LearningAttemptStatus.Submitted, false, true },
{ LearningMode.Exam, false, LearningAttemptStatus.Submitted, false, false },
{ LearningMode.Exam, false, LearningAttemptStatus.Reviewed, false, false },
};
[Theory]
[MemberData(nameof(Cases))]
public void Child_payload_contains_hints_and_solutions_only_when_the_rules_allow(
LearningMode mode, bool showSolutions, LearningAttemptStatus status, bool hintVisible, bool solutionVisible)
{
var (set, attempt) = Build(mode, showSolutions, status);
var json = Json(set, attempt);
Assert.Equal(hintVisible, json.Contains(Hint, StringComparison.Ordinal));
Assert.Equal(solutionVisible, json.Contains(Solution, StringComparison.Ordinal));
Assert.Equal(solutionVisible, json.Contains(Explanation, StringComparison.Ordinal));
}
[Fact]
public void Exam_in_progress_has_no_feedback_at_all()
{
var (set, attempt) = Build(LearningMode.Exam, true, LearningAttemptStatus.InProgress);
var dto = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.All(dto.Items, i => Assert.Null(i.Feedback));
Assert.All(dto.Items, i => Assert.Null(i.Hint));
Assert.Null(dto.Result);
Assert.Equal(["7", "1", "Hallo"], dto.Items.Select(i => i.Answer)); // die eigenen Antworten bleiben sichtbar
}
[Fact]
public void Practice_in_progress_shows_the_verdict_of_answered_items_but_never_the_solution()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = Guid.NewGuid(), Value = null, Verdict = LearningVerdict.Open }); // Zeile zu unbekannter Aufgabe wird ignoriert
var dto = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.Equal([LearningVerdict.Wrong, LearningVerdict.Correct, LearningVerdict.Open], dto.Items.Select(i => i.Feedback!.Verdict));
Assert.All(dto.Items, i => Assert.Null(i.Feedback!.CorrectAnswer));
Assert.All(dto.Items, i => Assert.Null(i.Feedback!.Explanation));
}
[Fact]
public void Unanswered_items_have_no_feedback_in_practice_until_submitted()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers.Clear();
Assert.All(LearningMapper.ToAttemptDto(attempt, set, Now).Items, i => Assert.Null(i.Feedback));
attempt.Status = LearningAttemptStatus.Submitted;
var submitted = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.All(submitted.Items, i => Assert.Equal(LearningVerdict.Open, i.Feedback!.Verdict));
Assert.All(submitted.Items, i => Assert.Equal(set.Items.Single(x => x.Id == i.ItemId).CorrectAnswer, i.Feedback!.CorrectAnswer)); // Lösungen nach Abgabe
}
[Fact]
public void Parent_reasons_reach_the_child_only_after_submission()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers[0].Reason = ParentReason;
attempt.Answers[0].Grader = LearningGrader.Parent;
attempt.Answers[1].Reason = "Keine Zahl erkannt."; // Meldung der Automatik: kein Kommentar der Eltern
Assert.DoesNotContain(ParentReason, Json(set, attempt));
attempt.Status = LearningAttemptStatus.Reviewed;
var json = Json(set, attempt);
Assert.Contains(ParentReason, json);
Assert.DoesNotContain("Keine Zahl erkannt.", json);
}
[Fact]
public void Child_dtos_have_no_properties_for_prompts_providers_or_solutions_at_the_set_level()
{
var names = typeof(MyLearningSetDto).GetProperties().Select(p => p.Name)
.Concat(typeof(LearningAttemptDto).GetProperties().Select(p => p.Name))
.Concat(typeof(MyLearningResultDto).GetProperties().Select(p => p.Name))
.Concat(typeof(LearningAttemptItemDto).GetProperties().Select(p => p.Name))
.ToList();
Assert.DoesNotContain(names, n => n.Contains("Prompt", StringComparison.Ordinal) || n.Contains("Provider", StringComparison.Ordinal) || n.Contains("Model", StringComparison.Ordinal));
Assert.DoesNotContain("CorrectAnswer", names);
Assert.DoesNotContain("Explanation", names);
}
[Fact]
public void Result_shows_the_grade_only_for_confirmed_exams()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
var items = set.Items.ToDictionary(i => i.Id);
var submitted = LearningMapper.ToResult(attempt, items, set);
Assert.Equal((false, null, 1, 4), (submitted.IsFinal, submitted.Grade, submitted.OpenCount, submitted.TotalItemPoints));
Assert.Equal(1, submitted.EarnedPoints); // nur die richtige Auswahl; die Zahl ist falsch, der Freitext offen
attempt.Status = LearningAttemptStatus.Reviewed;
attempt.EarnedPoints = 3;
attempt.TotalItemPoints = 4;
attempt.Grade = 3;
var reviewed = LearningMapper.ToResult(attempt, items, set);
Assert.Equal((true, 3, 3, 4), (reviewed.IsFinal, reviewed.Grade, reviewed.EarnedPoints, reviewed.TotalItemPoints));
set.Mode = LearningMode.Practice;
Assert.Null(LearningMapper.ToResult(attempt, items, set).Grade); // Übung: keine Note
}
[Fact]
public void Parent_review_shows_the_solution_and_a_grade_preview_only_when_nothing_is_open()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
var open = LearningMapper.ToReviewDto(attempt, set, "Mia", 0);
Assert.Equal(Solution, open.Answers[0].CorrectAnswer);
Assert.Equal((1, 4, 1, (int?)null), (open.EarnedPoints, open.TotalItemPoints, open.OpenCount, open.Grade));
attempt.Answers[2].Verdict = LearningVerdict.Correct;
attempt.Answers[2].AwardedPoints = 1;
var complete = LearningMapper.ToReviewDto(attempt, set, "Mia", 0);
Assert.Equal((2, 0, LearningGrades.GradeFor(2, 4)), (complete.EarnedPoints, complete.OpenCount, complete.Grade!.Value));
}
[Fact]
public void Custom_grade_scales_apply_to_the_preview()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
attempt.Answers[2].Verdict = LearningVerdict.Correct;
attempt.Answers[2].AwardedPoints = 1;
set.GradeThresholds = "50;40;30;20;10";
Assert.Equal(1, LearningMapper.ToReviewDto(attempt, set, "Mia", 0).Grade); // 2 von 4 = 50 % → 1 nach strengem Schlüssel
}
}
@@ -0,0 +1,352 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Zeitlimit, Versuche, Status, Sichtbarkeit (Prüfungsregeln), Reihenfolge, Punkte, Noten (ADR-0021).</summary>
public sealed class LearningRulesTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
// ---------- Zeitlimit ----------
[Fact]
public void Deadline_is_start_plus_limit_or_none()
{
Assert.Equal(Start.AddMinutes(20), LearningRules.DeadlineFor(Start, 20));
Assert.Null(LearningRules.DeadlineFor(Start, null));
Assert.Null(LearningRules.DeadlineFor(Start, 0));
}
[Fact]
public void Saves_are_accepted_until_the_deadline_plus_a_small_fixed_tolerance()
{
var deadline = Start.AddMinutes(10);
Assert.True(LearningRules.AcceptsSaves(null, Start.AddYears(1)));
Assert.True(LearningRules.AcceptsSaves(deadline, Start));
Assert.True(LearningRules.AcceptsSaves(deadline, deadline));
Assert.True(LearningRules.AcceptsSaves(deadline, deadline + LearningRules.DeadlineTolerance));
Assert.False(LearningRules.AcceptsSaves(deadline, deadline + LearningRules.DeadlineTolerance + TimeSpan.FromMilliseconds(1)));
Assert.InRange(LearningRules.DeadlineTolerance, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); // „klein“
}
[Fact]
public void Auto_submit_is_due_exactly_when_saves_are_no_longer_accepted()
{
var deadline = Start.AddMinutes(10);
Assert.False(LearningRules.MustAutoSubmit(null, Start.AddYears(1)));
Assert.False(LearningRules.MustAutoSubmit(deadline, deadline + LearningRules.DeadlineTolerance));
Assert.True(LearningRules.MustAutoSubmit(deadline, deadline + LearningRules.DeadlineTolerance + TimeSpan.FromTicks(1)));
}
[Fact]
public void Submission_time_is_never_after_the_deadline()
{
var deadline = Start.AddMinutes(10);
Assert.Equal(Start.AddMinutes(3), LearningRules.SubmittedAtFor(deadline, Start.AddMinutes(3)));
Assert.Equal(deadline, LearningRules.SubmittedAtFor(deadline, deadline.AddHours(5)));
Assert.Equal(Start, LearningRules.SubmittedAtFor(null, Start));
}
[Fact]
public void Remaining_time_counts_down_and_never_goes_negative()
{
var deadline = Start.AddMinutes(10);
Assert.Equal(TimeSpan.FromMinutes(7), LearningRules.Remaining(deadline, Start.AddMinutes(3)));
Assert.Equal(TimeSpan.Zero, LearningRules.Remaining(deadline, deadline.AddMinutes(1)));
Assert.Null(LearningRules.Remaining(null, Start));
}
// ---------- Status und Versuche ----------
private static readonly LearningAttemptStatus InProgress = LearningAttemptStatus.InProgress;
private static readonly LearningAttemptStatus Submitted = LearningAttemptStatus.Submitted;
private static readonly LearningAttemptStatus Reviewed = LearningAttemptStatus.Reviewed;
public static TheoryData<LearningSetStatus, LearningAttemptStatus[], LearningSetStatus> StatusCases => new()
{
{ LearningSetStatus.Draft, [], LearningSetStatus.Draft },
{ LearningSetStatus.Draft, [LearningAttemptStatus.InProgress], LearningSetStatus.Draft }, // Entwürfe bleiben Entwürfe
{ LearningSetStatus.Assigned, [], LearningSetStatus.Assigned },
{ LearningSetStatus.Assigned, [LearningAttemptStatus.InProgress], LearningSetStatus.InProgress },
{ LearningSetStatus.InProgress, [LearningAttemptStatus.Submitted], LearningSetStatus.Submitted },
{ LearningSetStatus.Submitted, [LearningAttemptStatus.Reviewed], LearningSetStatus.Reviewed },
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.InProgress], LearningSetStatus.InProgress }, // Wiederholung läuft
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.Submitted], LearningSetStatus.Submitted }, // erneut geöffnet
{ LearningSetStatus.Submitted, [LearningAttemptStatus.Submitted, LearningAttemptStatus.InProgress], LearningSetStatus.InProgress },
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.Reviewed], LearningSetStatus.Reviewed },
{ LearningSetStatus.Reviewed, [], LearningSetStatus.Assigned },
};
[Theory]
[MemberData(nameof(StatusCases))]
public void Set_status_is_derived_from_the_attempts(LearningSetStatus current, LearningAttemptStatus[] attempts, LearningSetStatus expected) =>
Assert.Equal(expected, LearningRules.DeriveStatus(current, attempts));
[Fact]
public void A_new_attempt_needs_an_assigned_sheet_and_respects_the_attempt_limit()
{
Assert.Equal(AttemptStart.NotAssigned, LearningRules.CanStartAttempt(LearningSetStatus.Draft, 0, []));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Assigned, 1, []));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 1, [Reviewed]));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Submitted, 1, [Submitted]));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 2, [Reviewed]));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 2, [Reviewed, Submitted]));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 0, Enumerable.Repeat(Reviewed, 50).ToList())); // 0 = unbegrenzt
}
[Fact]
public void A_running_attempt_is_resumed_not_counted_twice()
{
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 1, [InProgress]));
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 2, [Reviewed, InProgress]));
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 0, [InProgress]));
}
[Theory]
[InlineData(0, 5, int.MaxValue)]
[InlineData(1, 0, 1)]
[InlineData(3, 1, 2)]
[InlineData(3, 3, 0)]
[InlineData(3, 9, 0)]
public void Attempts_left(int max, int used, int expected) => Assert.Equal(expected, LearningRules.AttemptsLeft(max, used));
[Fact]
public void Items_are_editable_only_before_the_first_attempt()
{
Assert.True(LearningRules.CanEditItems(0));
Assert.False(LearningRules.CanEditItems(1));
}
[Fact]
public void Only_drafts_with_valid_items_can_be_assigned()
{
var valid = new LearningItem { AnswerType = LearningAnswerType.Number, CorrectAnswer = "4", SortOrder = 0 };
var broken = new LearningItem { AnswerType = LearningAnswerType.Number, CorrectAnswer = "vier", SortOrder = 1 };
Assert.Null(LearningRules.CheckAssignable(LearningSetStatus.Draft, [valid]));
Assert.Equal("Das Blatt ist bereits zugewiesen.", LearningRules.CheckAssignable(LearningSetStatus.Assigned, [valid]));
Assert.Equal("Das Blatt braucht mindestens eine Aufgabe.", LearningRules.CheckAssignable(LearningSetStatus.Draft, []));
Assert.StartsWith("Aufgabe 2:", LearningRules.CheckAssignable(LearningSetStatus.Draft, [valid, broken]));
}
// ---------- Rechte ----------
[Theory]
[InlineData(Roles.Child, true, LearningSetStatus.Assigned, true)]
[InlineData(Roles.Child, true, LearningSetStatus.InProgress, true)]
[InlineData(Roles.Child, true, LearningSetStatus.Reviewed, true)]
[InlineData(Roles.Child, true, LearningSetStatus.Draft, false)] // Entwürfe sind unsichtbar
[InlineData(Roles.Child, false, LearningSetStatus.Assigned, false)] // fremdes Kind
[InlineData(Roles.Family, false, LearningSetStatus.Assigned, false)]// auch Eltern lösen nicht für andere
[InlineData(Roles.Guest, true, LearningSetStatus.Assigned, false)] // Gäste nie
public void Solving_is_limited_to_the_target_child_and_assigned_sheets(string role, bool isTarget, LearningSetStatus status, bool expected)
{
var me = Guid.NewGuid();
var actor = LearningActor.From(me, [role]);
Assert.Equal(expected, LearningRules.CanSolve(actor, isTarget ? me : Guid.NewGuid(), status));
}
[Theory]
[InlineData(Roles.Admin, true, AccessLevel.Full)]
[InlineData(Roles.Family, true, AccessLevel.Full)]
[InlineData(Roles.Child, false, AccessLevel.Restricted)]
[InlineData(Roles.Guest, false, AccessLevel.None)]
public void Actor_reflects_the_permission_matrix(string role, bool canManage, AccessLevel solve)
{
var actor = LearningActor.From(Guid.NewGuid(), [role]);
Assert.Equal((canManage, solve), (actor.CanManage, actor.Solve));
}
// ---------- Sichtbarkeit / Prüfungsregeln ----------
public static TheoryData<LearningMode, LearningAttemptStatus, bool, bool, bool, bool> VisibilityCases => new()
{
// Modus, Status, Lösungen nach Abgabe, Hinweis, Bewertung, Lösung
{ LearningMode.Practice, LearningAttemptStatus.InProgress, true, true, true, false }, // Übung: Sofort-Rückmeldung + Hinweise, Lösung erst nach Abgabe
{ LearningMode.Practice, LearningAttemptStatus.InProgress, false, true, true, false },
{ LearningMode.Practice, LearningAttemptStatus.Submitted, true, true, true, true },
{ LearningMode.Practice, LearningAttemptStatus.Submitted, false, true, true, false },
{ LearningMode.Practice, LearningAttemptStatus.Reviewed, true, true, true, true },
{ LearningMode.Exam, LearningAttemptStatus.InProgress, true, false, false, false }, // Prüfung: nichts bis zur Abgabe, auch wenn „Lösungen zeigen“ an ist
{ LearningMode.Exam, LearningAttemptStatus.InProgress, false, false, false, false },
{ LearningMode.Exam, LearningAttemptStatus.Submitted, true, false, true, true }, // danach Bewertung, nie Hinweise
{ LearningMode.Exam, LearningAttemptStatus.Submitted, false, false, true, false },
{ LearningMode.Exam, LearningAttemptStatus.Reviewed, false, false, true, false },
{ LearningMode.Exam, LearningAttemptStatus.Reviewed, true, false, true, true },
};
[Theory]
[MemberData(nameof(VisibilityCases))]
public void Visibility_follows_mode_status_and_setting(
LearningMode mode, LearningAttemptStatus status, bool showSolutions, bool hint, bool verdict, bool solution)
{
var visibility = LearningRules.VisibilityFor(mode, status, showSolutions);
Assert.Equal((hint, verdict, solution), (visibility.ShowHint, visibility.ShowVerdict, visibility.ShowSolution));
}
[Fact]
public void A_solution_is_never_visible_while_the_attempt_is_running()
{
foreach (var mode in Enum.GetValues<LearningMode>())
{
foreach (var setting in new[] { true, false })
{
Assert.False(LearningRules.VisibilityFor(mode, LearningAttemptStatus.InProgress, setting).ShowSolution);
}
}
}
// ---------- Reihenfolge ----------
[Fact]
public void Unshuffled_order_keeps_the_sort_order()
{
var ids = Enumerable.Range(0, 8).Select(_ => Guid.NewGuid()).ToList();
Assert.Equal(ids, LearningRules.OrderItems(ids, false, Guid.NewGuid()));
}
[Fact]
public void Shuffled_order_is_stable_per_attempt_complete_and_differs_between_attempts()
{
var ids = Enumerable.Range(0, 12).Select(i => new Guid(i, 0, 0, [0, 0, 0, 0, 0, 0, 0, 1])).ToList();
var attempt = new Guid("11111111-2222-3333-4444-555555555555");
var first = LearningRules.OrderItems(ids, true, attempt);
var again = LearningRules.OrderItems(ids, true, attempt);
Assert.Equal(first, again);
Assert.Equal(ids.Order(), first.Order()); // nichts verloren, nichts doppelt
Assert.NotEqual(ids, first); // tatsächlich gemischt (Wahrscheinlichkeit des Zufallstreffers 1/12!)
var others = Enumerable.Range(0, 5).Select(i => LearningRules.OrderItems(ids, true, new Guid(i + 1, 9, 9, [1, 2, 3, 4, 5, 6, 7, 8]))).ToList();
Assert.Contains(others, o => !o.SequenceEqual(first));
}
[Fact]
public void Shuffled_order_is_deterministic_across_processes()
{
// Fester Erwartungswert: die Mischung hängt nur von SHA-256 ab, nicht von string.GetHashCode (pro Prozess zufällig).
var ids = new[] { "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000003" }
.Select(Guid.Parse).ToList();
var attempt = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
var order = LearningRules.OrderItems(ids, true, attempt);
Assert.Equal(LearningRules.OrderItems(ids, true, attempt), order);
Assert.Equal(ids.Count, order.Distinct().Count());
Assert.Equal("2,1,3", string.Join(",", order.Select(o => o.ToString()[^1]))); // gepinnt: ändert sich die Mischung, wären laufende Versuche betroffen
}
[Fact]
public void Item_order_survives_serialisation_and_ignores_garbage()
{
var ids = Enumerable.Range(0, 5).Select(_ => Guid.NewGuid()).ToList();
Assert.Equal(ids, LearningRules.ParseOrder(LearningRules.SerializeOrder(ids)));
Assert.Empty(LearningRules.ParseOrder(null));
Assert.Empty(LearningRules.ParseOrder(""));
Assert.Equal([ids[0]], LearningRules.ParseOrder($"{ids[0]},kaputt,,{Guid.Empty}"));
Assert.True(LearningRules.SerializeOrder(Enumerable.Range(0, LearningSet.MaxItems).Select(_ => Guid.NewGuid())).Length <= LearningAttempt.ItemOrderMaxLength);
}
// ---------- Elternbewertung ----------
[Theory]
[InlineData(LearningVerdict.Correct, null, 4, 4, null)]
[InlineData(LearningVerdict.Correct, 2, 4, 4, null)] // Punkte werden bei „richtig“ ignoriert
[InlineData(LearningVerdict.Wrong, 3, 4, 0, null)]
[InlineData(LearningVerdict.Partial, 3, 4, 3, null)]
[InlineData(LearningVerdict.Partial, 1, 4, 1, null)]
[InlineData(LearningVerdict.Partial, null, 4, 2, null)] // Standard: Hälfte
[InlineData(LearningVerdict.Partial, null, 3, 1, null)] // abgerundet
[InlineData(LearningVerdict.Partial, null, 2, 1, null)]
public void Parent_verdicts_are_normalised(LearningVerdict verdict, int? awarded, int itemPoints, int expectedPoints, string? expectedError)
{
var (points, error) = LearningRules.NormalizeParentVerdict(verdict, awarded, itemPoints);
Assert.Equal((expectedPoints, expectedError), (points, error));
}
[Theory]
[InlineData(LearningVerdict.Partial, 4, 4)] // teilweise darf nicht alles sein
[InlineData(LearningVerdict.Partial, 0, 4)]
[InlineData(LearningVerdict.Partial, -1, 4)]
[InlineData(LearningVerdict.Partial, 1, 1)] // bei 1 Punkt gibt es keine Teilpunkte
[InlineData(LearningVerdict.Partial, null, 1)]
[InlineData(LearningVerdict.Open, null, 4)] // „offen“ ist kein Prüfergebnis
public void Invalid_parent_verdicts_are_rejected(LearningVerdict verdict, int? awarded, int itemPoints) =>
Assert.NotNull(LearningRules.NormalizeParentVerdict(verdict, awarded, itemPoints).Error);
// ---------- Punkte ----------
[Fact]
public void Score_counts_correct_partial_and_ignores_open_answers()
{
var score = LearningRules.Score(
[
new ScoredAnswer(2, LearningVerdict.Correct, 2),
new ScoredAnswer(4, LearningVerdict.Partial, 3),
new ScoredAnswer(1, LearningVerdict.Wrong, 0),
new ScoredAnswer(3, LearningVerdict.Open, 0),
new ScoredAnswer(2, LearningVerdict.Partial, 9), // zu viele Teilpunkte werden auf die Aufgabenpunkte begrenzt
]);
Assert.Equal((7, 12, 1, 1, 5), (score.Earned, score.Total, score.OpenCount, score.CorrectCount, score.AnswerCount));
Assert.Equal(7 * 100.0 / 12, score.Percent, 6);
Assert.Equal(0, LearningRules.Score([]).Percent);
}
[Theory]
[InlineData(10, 4, 4, 10)]
[InlineData(10, 3, 4, 8)] // 7,5 → 8 (kaufmännisch)
[InlineData(10, 2, 4, 5)]
[InlineData(10, 1, 4, 3)] // 2,5 → 3
[InlineData(10, 0, 4, 0)]
[InlineData(7, 1, 3, 2)] // 2,33 → 2
[InlineData(7, 2, 3, 5)] // 4,67 → 5
[InlineData(20, 3, 4, 15)]
[InlineData(0, 4, 4, 0)] // Blatt ohne Punkte
[InlineData(100, 1, 3, 33)]
public void Credit_is_proportional_and_rounded_half_up(int sheetPoints, int earned, int total, int expected) =>
Assert.Equal(expected, LearningRules.TargetCredit(sheetPoints, [(earned, total)]));
[Fact]
public void Only_the_best_confirmed_attempt_counts_once()
{
Assert.Equal(0, LearningRules.TargetCredit(10, []));
Assert.Equal(8, LearningRules.TargetCredit(10, [(2, 4), (3, 4), (1, 4)]));
Assert.Equal(10, LearningRules.TargetCredit(10, [(2, 4), (4, 4), (4, 4)])); // gleich gute Versuche zählen nicht doppelt
Assert.Equal(7, LearningRules.TargetCredit(10, [(7, 10)]));
// Anteile statt absoluter Punkte: 2/3 (67 %) schlägt 3/5 (60 %)
Assert.Equal(7, LearningRules.TargetCredit(10, [(3, 5), (2, 3)]));
Assert.Equal(0, LearningRules.TargetCredit(10, [(0, 0)])); // leerer Versuch ohne Punkte
}
[Theory]
[InlineData(8, 5, 3)]
[InlineData(5, 8, -3)] // Rückbuchung
[InlineData(5, 5, 0)] // idempotent
[InlineData(0, 0, 0)]
public void Credit_delta_is_the_difference_to_what_was_booked(int target, int booked, int expected) =>
Assert.Equal(expected, LearningRules.CreditDelta(target, booked));
[Fact]
public void Reopening_the_best_attempt_falls_back_to_the_next_best()
{
var all = new[] { (2, 4), (3, 4), (1, 4) };
var booked = LearningRules.TargetCredit(10, all); // 8
var afterReopen = LearningRules.TargetCredit(10, all.Where(a => a != (3, 4)));
Assert.Equal((8, 5, -3), (booked, afterReopen, LearningRules.CreditDelta(afterReopen, booked)));
}
}
@@ -0,0 +1,153 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.UnitTests.Learning;
public sealed class LearningValidatorTests
{
private static readonly Guid Child = Guid.NewGuid();
private static LearningItemInput Number() => new("Was ist 7 + 5?", LearningAnswerType.Number, "12", Points: 2, Difficulty: 1);
private static CreateLearningSetRequest Valid() => new(
"Plus und Minus", LearningSubjects.Math, 2, Child, new DateOnly(2026, 10, 20), LearningMode.Exam, 1, 30, false, true, 10, "90;80;70;60;50",
[
Number(),
new("Hauptstadt?", LearningAnswerType.SingleChoice, "1", Options: ["Paris", "Berlin"], Hint: "Spree"),
new("Schreibe „Haus“.", LearningAnswerType.Text, "Haus"),
]);
[Fact]
public void Valid_requests_pass()
{
var validator = new CreateLearningSetRequestValidator();
Assert.True(validator.Validate(Valid()).IsValid);
Assert.True(validator.Validate(Valid() with { Items = null, GradeThresholds = null, TimeLimitMinutes = null, MaxAttempts = 0 }).IsValid);
Assert.True(new UpdateLearningSetRequestValidator().Validate(new UpdateLearningSetRequest("Titel", LearningSubjects.German, 6, Child)).IsValid);
}
public static TheoryData<CreateLearningSetRequest, string> InvalidSets => new()
{
{ Valid() with { Title = " " }, nameof(CreateLearningSetRequest.Title) },
{ Valid() with { Title = new string('x', LearningSet.TitleMaxLength + 1) }, nameof(CreateLearningSetRequest.Title) },
{ Valid() with { Subject = "Sport" }, nameof(CreateLearningSetRequest.Subject) },
{ Valid() with { Subject = "mathe" }, nameof(CreateLearningSetRequest.Subject) },
{ Valid() with { GradeLevel = 0 }, nameof(CreateLearningSetRequest.GradeLevel) },
{ Valid() with { GradeLevel = 7 }, nameof(CreateLearningSetRequest.GradeLevel) },
{ Valid() with { TargetUserId = Guid.Empty }, nameof(CreateLearningSetRequest.TargetUserId) },
{ Valid() with { Mode = (LearningMode)5 }, nameof(CreateLearningSetRequest.Mode) },
{ Valid() with { MaxAttempts = -1 }, nameof(CreateLearningSetRequest.MaxAttempts) },
{ Valid() with { MaxAttempts = LearningSet.AttemptsLimit + 1 }, nameof(CreateLearningSetRequest.MaxAttempts) },
{ Valid() with { TimeLimitMinutes = 0 }, nameof(CreateLearningSetRequest.TimeLimitMinutes) },
{ Valid() with { TimeLimitMinutes = LearningSet.MaxTimeLimitMinutes + 1 }, nameof(CreateLearningSetRequest.TimeLimitMinutes) },
{ Valid() with { TotalPoints = -1 }, nameof(CreateLearningSetRequest.TotalPoints) },
{ Valid() with { TotalPoints = LearningSet.MaxTotalPoints + 1 }, nameof(CreateLearningSetRequest.TotalPoints) },
{ Valid() with { GradeThresholds = "1;2;3" }, nameof(CreateLearningSetRequest.GradeThresholds) },
{ Valid() with { GradeThresholds = "30;50;67;81;92" }, nameof(CreateLearningSetRequest.GradeThresholds) },
{ Valid() with { Items = Enumerable.Range(0, LearningSet.MaxItems + 1).Select(_ => Number()).ToList() }, nameof(CreateLearningSetRequest.Items) },
};
[Theory]
[MemberData(nameof(InvalidSets))]
public void Invalid_sets_are_rejected_with_a_field_error(CreateLearningSetRequest request, string field)
{
var result = new CreateLearningSetRequestValidator().Validate(request);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == field);
}
public static TheoryData<LearningItemInput, string> InvalidItems => new()
{
{ Number() with { Question = " " }, nameof(LearningItemInput.Question) },
{ Number() with { Question = new string('x', LearningItem.QuestionMaxLength + 1) }, nameof(LearningItemInput.Question) },
{ Number() with { AnswerType = LearningAnswerType.Paper }, nameof(LearningItemInput.AnswerType) }, // erst Phase 10c
{ Number() with { AnswerType = (LearningAnswerType)9 }, nameof(LearningItemInput.AnswerType) },
{ Number() with { CorrectAnswer = "zwölf" }, nameof(LearningItemInput.CorrectAnswer) },
{ Number() with { CorrectAnswer = null }, nameof(LearningItemInput.CorrectAnswer) },
{ Number() with { CorrectAnswer = new string('1', LearningItem.CorrectAnswerMaxLength + 1) }, nameof(LearningItemInput.CorrectAnswer) },
{ new("Text?", LearningAnswerType.Text, " "), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["nur eine"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: null), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "2", Options: ["a", "b"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "x", Options: ["a", "b"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["a", "b", "c", "d", "e", "f", "g"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["a", " "]), nameof(LearningItemInput.Options) },
{ Number() with { Difficulty = 0 }, nameof(LearningItemInput.Difficulty) },
{ Number() with { Difficulty = 4 }, nameof(LearningItemInput.Difficulty) },
{ Number() with { Points = 0 }, nameof(LearningItemInput.Points) },
{ Number() with { Points = LearningItem.MaxPoints + 1 }, nameof(LearningItemInput.Points) },
{ Number() with { NumberTolerance = -1m }, nameof(LearningItemInput.NumberTolerance) },
{ Number() with { Hint = new string('h', LearningItem.HintMaxLength + 1) }, nameof(LearningItemInput.Hint) },
{ Number() with { Explanation = new string('e', LearningItem.ExplanationMaxLength + 1) }, nameof(LearningItemInput.Explanation) },
};
[Theory]
[MemberData(nameof(InvalidItems))]
public void Invalid_items_are_rejected_with_a_field_error(LearningItemInput item, string field)
{
var result = new LearningItemInputValidator().Validate(item);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == field);
}
[Fact]
public void Invalid_items_inside_a_set_and_update_requests_are_rejected_too()
{
var result = new CreateLearningSetRequestValidator().Validate(Valid() with { Items = [Number(), Number() with { Points = 99 }] });
Assert.False(result.IsValid);
var update = UpdateLearningItemRequest.From(new LearningItemDto(Guid.NewGuid(), 0, "Frage", LearningAnswerType.Number, "5", null, [], null, null, 1, 1, [1]));
Assert.True(new UpdateLearningItemRequestValidator().Validate(update).IsValid);
Assert.False(new UpdateLearningItemRequestValidator().Validate(update with { CorrectAnswer = "x" }).IsValid);
}
[Fact]
public void Item_entities_are_built_from_the_fields_without_leftovers()
{
var item = LearningValidation.ToItem(new LearningItemInput(
" Frage ", LearningAnswerType.Number, " 12 ", 0.5m, ["ignoriert"], " Hinweis ", " ", 2, 3));
Assert.Equal(("Frage", "12", 0.5m, "Hinweis", null), (item.Question, item.CorrectAnswer, item.NumberTolerance, item.Hint, item.Explanation));
Assert.Empty(item.Options); // Optionen gibt es nur bei Auswahlaufgaben
var choice = LearningValidation.ToItem(new LearningItemInput("Wahl", LearningAnswerType.SingleChoice, "1", 3m, [" a ", "b"]));
Assert.Equal(["a", "b"], choice.Options);
Assert.Null(choice.NumberTolerance); // Toleranz nur bei Zahlen
}
[Fact]
public void Reorder_save_answer_review_and_confirm_requests_are_validated()
{
var ids = new[] { Guid.NewGuid(), Guid.NewGuid() };
Assert.True(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest(ids)).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest([ids[0], ids[0]])).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest([Guid.Empty])).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest(null!)).IsValid);
Assert.True(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest("12")).IsValid);
Assert.True(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest(null)).IsValid);
Assert.False(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest(new string('x', LearningAnswer.ValueMaxLength + 1))).IsValid);
var review = new ReviewAnswerRequestValidator();
Assert.True(review.Validate(new ReviewAnswerRequest(LearningVerdict.Correct)).IsValid);
Assert.True(review.Validate(new ReviewAnswerRequest(LearningVerdict.Partial, 2, "Fast richtig")).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Open)).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Partial, 0)).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Wrong, null, new string('x', LearningAnswer.ReasonMaxLength + 1))).IsValid);
Assert.True(new ConfirmAttemptRequestValidator().Validate(new ConfirmAttemptRequest(null)).IsValid);
Assert.False(new ConfirmAttemptRequestValidator().Validate(new ConfirmAttemptRequest(new string('x', LearningAttempt.NoteMaxLength + 1))).IsValid);
}
[Fact]
public void Subjects_are_a_fixed_list_for_now()
{
Assert.Equal(["Mathe", "Deutsch"], LearningSubjects.All);
Assert.True(LearningSubjects.IsValid("Mathe"));
Assert.False(LearningSubjects.IsValid("Englisch"));
Assert.False(LearningSubjects.IsValid(null));
}
}
@@ -46,6 +46,18 @@ public sealed class AuthenticationStateProviderTests
var admin = Assert.Single(NavItems.All, i => i.Href == "admin");
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, admin.Policy);
Assert.All(NavItems.All.Where(i => i.Href != "admin"), i => Assert.Null(i.Policy));
Assert.True(admin.Separated);
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen")), i => Assert.Null(i.Policy));
}
[Fact]
public void Learning_navigation_is_for_family_members_only_and_not_in_the_bottom_bar()
{
var learning = Assert.Single(NavItems.All, i => i.Href == "lernen");
Assert.Equal("Lernen", learning.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.FamilyMember, learning.Policy); // Admin, Familie, Kind – keine Gäste
Assert.False(learning.InBottomNav);
Assert.False(learning.Separated);
}
}
@@ -281,6 +281,101 @@ public sealed class HomePageTests : BunitContext
Assert.NotNull(cut);
}
// ---------- Kachel „Lernen“ (ADR-0021) ----------
private static DashboardDto WithLearning(DashboardLearningDto? learning) =>
Everything() with { Learning = learning };
[Fact]
public void Kids_see_their_open_sheets_with_deadlines_and_a_link_to_continue()
{
var running = Guid.NewGuid();
_api.Data = WithLearning(new DashboardLearningDto(2, 0, false,
[
new(Guid.NewGuid(), running, "Plus und Minus", "Mathe", Today.AddDays(-1), true, null),
new(Guid.NewGuid(), null, "Diktat", "Deutsch", Today.AddDays(2), false, null),
]));
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
Assert.Contains("Lernen", cut.FindAll(".fp-tile h2").Select(h => h.TextContent.Trim()));
var tile = cut.Find(".fp-dash-learning");
Assert.Contains("2 offene Übungsblätter", tile.QuerySelector(".fp-dash-learning-summary")!.TextContent);
var items = tile.QuerySelectorAll(".fp-dash-learning-list li");
Assert.Equal(2, items.Length);
Assert.Equal($"lernen/versuch/{running}", items[0].QuerySelector("a")!.GetAttribute("href")); // laufender Versuch → direkt weitermachen
Assert.Contains("Mathe · überfällig seit 11.10.", items[0].TextContent);
Assert.Contains("überfällig", items[0].QuerySelector(".fp-dash-overdue")!.TextContent);
Assert.Equal("lernen", items[1].QuerySelector("a")!.GetAttribute("href"));
Assert.Contains("Deutsch · fällig Mi., 14.10.", items[1].TextContent);
Assert.Empty(items[1].QuerySelectorAll(".fp-dash-overdue"));
Assert.Equal("lernen", tile.QuerySelector(".mud-card-actions a")!.GetAttribute("href"));
}
[Fact]
public void Parents_see_submissions_to_review_and_open_sheets_of_the_children()
{
var attempt = Guid.NewGuid();
_api.Data = WithLearning(new DashboardLearningDto(3, 2, true, [new(Guid.NewGuid(), attempt, "Mathe-Test", "Mathe", null, false, "Mia")]));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
var tile = cut.Find(".fp-dash-learning");
Assert.Contains("2 Abgaben zu prüfen", tile.QuerySelector(".fp-dash-learning-summary")!.TextContent);
Assert.Contains("3 Blätter offen", tile.QuerySelector(".fp-dash-learning-open")!.TextContent);
var link = tile.QuerySelector(".fp-dash-learning-list a")!;
Assert.Equal($"lernen/pruefen/{attempt}", link.GetAttribute("href"));
Assert.Contains("Mia: Mathe-Test", link.TextContent);
}
[Fact]
public void The_learning_tile_has_friendly_empty_states_and_is_absent_without_the_right()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, true, []));
SignInAs(Roles.Admin);
var parents = Render<DashboardHost>();
Assert.Contains("Nichts zu prüfen.", parents.Find(".fp-dash-learning-summary").TextContent);
Assert.Empty(parents.FindAll(".fp-dash-learning-list"));
Assert.Empty(parents.FindAll(".fp-dash-learning-open"));
}
[Fact]
public void Children_without_open_sheets_are_praised_and_guests_get_no_tile()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, false, []));
SignInAs(Roles.Child);
var kid = Render<DashboardHost>();
Assert.Contains("Keine offenen Übungsblätter – super!", kid.Find(".fp-dash-learning-summary").TextContent);
}
[Fact]
public void Dashboards_without_a_learning_tile_show_no_learning_card()
{
_api.Data = WithLearning(null);
SignInAs(Roles.Guest);
var cut = Render<DashboardHost>();
Assert.Empty(cut.FindAll(".fp-dash-learning"));
}
[Fact]
public void Live_learning_events_reload_the_dashboard()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, true, []));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
var before = _api.Calls;
_api.Data = WithLearning(new DashboardLearningDto(1, 1, true, [new(Guid.NewGuid(), Guid.NewGuid(), "Neu abgegeben", "Mathe", null, false, "Ben")]));
cut.InvokeAsync(() => _live.RaiseLearningChanged(new FamilyPlanner.Shared.Learning.LearningChangedDto(Guid.NewGuid(), FamilyPlanner.Shared.Learning.LearningChangeKind.Submitted)));
cut.WaitForAssertion(() => Assert.True(_api.Calls > before));
cut.WaitForAssertion(() => Assert.Contains("1 Abgabe zu prüfen", cut.Find(".fp-dash-learning-summary").TextContent));
}
private static TaskDto NewTask(string title) => new(Guid.NewGuid(), title, null, null, null, null, null, TodoPriority.Normal, TodoStatus.Open, null,
false, false, 0, false, false, [], null, default, null, [], null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, []);
@@ -0,0 +1,216 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Prüfung durch Eltern: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen, erneut öffnen.</summary>
public sealed class AttemptReviewTests : LearningTestBase
{
private static LearningReviewAnswerDto Answer(int position, string question, LearningVerdict verdict, int points = 2, string? given = "Antwort", string? correct = "Lösung",
LearningGrader grader = LearningGrader.Auto, int awarded = 0, string? reason = null, LearningAnswerType type = LearningAnswerType.Text, IReadOnlyList<string>? options = null) =>
new(Guid.NewGuid(), position, question, type, options ?? [], correct, null, points, given, verdict, grader, awarded, reason);
private LearningAttemptReviewDto Review(
LearningAttemptStatus status = LearningAttemptStatus.Submitted,
LearningMode mode = LearningMode.Practice,
bool auto = false,
int? grade = null,
params LearningReviewAnswerDto[] answers)
{
var rows = answers.Length > 0
? answers
:
[
Answer(0, "Was ist 9 + 3?", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number),
Answer(1, "Nenne ein Haustier.", LearningVerdict.Open, 3, "Dackel", "Hund"),
];
var score = LearningRules.Score(rows.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
var review = new LearningAttemptReviewDto(
Guid.NewGuid(), Guid.NewGuid(), "Mathe-Test", LearningSubjects.Math, mode, 10, FakeLearningApi.Mia, "Mia", 2, status,
FakeLearningApi.Now.AddMinutes(-30), FakeLearningApi.Now.AddMinutes(-5), auto, rows, score.Earned, rows.Sum(r => r.ItemPoints), score.OpenCount, grade, 0, null, [1]);
Api.Reviews[review.AttemptId] = review;
return review;
}
private IRenderedComponent<LearningHost> Open(LearningAttemptReviewDto review)
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<AttemptReview>(c => c.Add(x => x.AttemptId, review.AttemptId)));
}
[Fact]
public void Summary_and_answers_show_child_answer_solution_verdict_and_who_graded()
{
var review = Review(answers:
[
Answer(0, "Was ist 9 + 3?", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number),
Answer(1, "Hauptstadt?", LearningVerdict.Wrong, 1, "0", "1", type: LearningAnswerType.SingleChoice, options: ["Paris", "Berlin"]),
Answer(2, "Aufsatz", LearningVerdict.Partial, 4, "Mein Hund", "Siehe Heft", LearningGrader.Parent, 3, "Satzbau"),
Answer(3, "Nenne ein Haustier.", LearningVerdict.Open, 1, "Dackel", "Hund"),
]);
var cut = Open(review);
Assert.Equal("Mia – Mathe-Test", cut.Find(".fp-review-summary h2").TextContent);
Assert.Contains("Versuch 2", cut.Find(".fp-review-summary").TextContent);
Assert.Contains("abgegeben 12.10.2026 09:55", cut.Find(".fp-review-summary").TextContent);
Assert.Equal("5 Punkte von 8", Text(cut.Find(".fp-review-score")));
Assert.Contains("1 Antwort offen", cut.Find(".fp-review-open").TextContent);
Assert.Empty(cut.FindAll(".fp-review-confirmed"));
var rows = cut.FindAll(".fp-review-answer");
Assert.Equal(4, rows.Count);
Assert.Equal(["Richtig", "Falsch", "Teilweise richtig (3 von 4)", "Wird geprüft"], rows.Select(r => Text(r.QuerySelector(".fp-review-verdict")!)).ToList());
Assert.Contains("Antwort: Paris", Text(rows[1].QuerySelector(".fp-review-given")!)); // Index → Optionstext
Assert.Contains("Musterlösung: Berlin", Text(rows[1].QuerySelector(".fp-review-correct")!));
Assert.Contains("automatisch bewertet", rows[0].QuerySelector(".fp-review-grader")!.TextContent);
Assert.Contains("von den Eltern bewertet: Satzbau", rows[2].QuerySelector(".fp-review-grader")!.TextContent);
}
[Fact]
public void Confirm_is_disabled_until_every_open_answer_is_rated()
{
var review = Review();
var cut = Open(review);
Assert.True(cut.Find(".fp-review-confirm").HasAttribute("disabled"));
Assert.Contains("Bitte zuerst alle offenen Antworten bewerten.", cut.Find(".fp-review-hint").TextContent);
cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-correct-button")!.Click();
cut.WaitForAssertion(() => Assert.False(cut.Find(".fp-review-confirm").HasAttribute("disabled")));
Assert.Empty(cut.FindAll(".fp-review-hint"));
var (attempt, item, request) = Assert.Single(Api.Reviewed);
Assert.Equal((review.AttemptId, review.Answers[1].ItemId, LearningVerdict.Correct, (int?)null), (attempt, item, request.Verdict, request.AwardedPoints));
Assert.Equal("5 Punkte von 5", Text(cut.Find(".fp-review-score")));
Assert.Contains("von den Eltern bewertet", cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-grader")!.TextContent);
Assert.Equal("true", cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-correct-button")!.GetAttribute("aria-pressed"));
}
[Fact]
public void Partial_points_and_a_reason_are_sent_with_the_verdict()
{
var review = Review();
var cut = Open(review);
AngleSharp.Dom.IElement Row() => cut.FindAll(".fp-review-answer")[1]; // nach jeder Eingabe neu suchen (die Ansicht wird neu gerendert)
Assert.Equal("1", Row().QuerySelector(".fp-review-points input")!.GetAttribute("value")); // Standard: Hälfte, mindestens 1 (3 Punkte → 1)
Row().QuerySelector(".fp-review-points input")!.Change("2");
Row().QuerySelector(".fp-review-reason input")!.Change("Dackel ist ein Hund, aber andere Schreibweise");
Row().QuerySelector(".fp-review-partial-button")!.Click();
cut.WaitForAssertion(() => Assert.Single(Api.Reviewed));
var request = Api.Reviewed[0].Request;
Assert.Equal((LearningVerdict.Partial, 2, "Dackel ist ein Hund, aber andere Schreibweise"), (request.Verdict, request.AwardedPoints, request.Reason));
cut.WaitForAssertion(() => Assert.Contains("Teilweise richtig (2 von 3)", cut.FindAll(".fp-review-verdict")[1].TextContent));
}
[Fact]
public void One_point_items_offer_no_partial_credit()
{
var review = Review(answers: [Answer(0, "Einpunkter", LearningVerdict.Open, 1)]);
var cut = Open(review);
Assert.Empty(cut.FindAll(".fp-review-partial-button"));
Assert.Empty(cut.FindAll(".fp-review-points"));
Assert.NotEmpty(cut.FindAll(".fp-review-wrong-button"));
}
[Fact]
public void Automatic_verdicts_can_be_corrected()
{
var review = Review(answers: [Answer(0, "Zahl", LearningVerdict.Wrong, 2, "12,5", "12,50", type: LearningAnswerType.Number)]);
var cut = Open(review);
cut.Find(".fp-review-correct-button").Click();
cut.WaitForAssertion(() => Assert.Equal("Richtig", Text(cut.Find(".fp-review-verdict"))));
Assert.Equal("2 Punkte von 2", Text(cut.Find(".fp-review-score")));
}
[Fact]
public void Confirming_sends_the_note_and_shows_the_credited_points_with_a_reopen_option()
{
var review = Review(answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]);
var cut = Open(review);
Assert.Empty(cut.FindAll(".fp-review-hint"));
cut.Find(".fp-review-note textarea").Change("Super gemacht!");
cut.Find(".fp-review-confirm").Click();
cut.WaitForAssertion(() => Assert.Single(Api.Confirmed));
Assert.Equal((review.AttemptId, "Super gemacht!"), (Api.Confirmed[0].Attempt, Api.Confirmed[0].Request.Note));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirmed")));
Assert.Contains("insgesamt 7 Punkte für dieses Blatt gutgeschrieben", cut.Find(".fp-review-credited").TextContent);
Assert.Empty(cut.FindAll(".fp-review-actions")); // bestätigte Versuche sind schreibgeschützt
Assert.Empty(cut.FindAll(".fp-review-confirm"));
Assert.Contains("Bestätigt – 7 Punkte gutgeschrieben.", cut.Markup); // Hinweis (Snackbar)
cut.Find(".fp-review-reopen").Click();
cut.WaitForAssertion(() => Assert.Equal([review.AttemptId], Api.Reopened));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirm")));
Assert.Empty(cut.FindAll(".fp-review-confirmed"));
Assert.Contains("7 Punkte zurückgebucht", cut.Markup);
}
[Fact]
public void Exam_shows_a_grade_preview_and_the_final_grade_after_confirmation()
{
var done = Review(LearningAttemptStatus.Submitted, LearningMode.Exam, grade: 2,
answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]);
var cut = Open(done);
Assert.Equal("Vorschau: Note 2 – gut", Text(cut.Find(".fp-review-grade")));
cut.Find(".fp-review-confirm").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirmed")));
}
[Fact]
public void Reviewed_attempts_show_the_final_grade_without_preview_label()
{
var review = Review(LearningAttemptStatus.Reviewed, LearningMode.Exam, grade: 3,
answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]) with { PointsCredited = 5 };
Api.Reviews[review.AttemptId] = review;
var cut = Open(review);
Assert.Equal("Note 3 – befriedigend", Text(cut.Find(".fp-review-grade")));
Assert.NotEmpty(cut.FindAll(".fp-review-confirmed"));
Assert.Contains("5 Punkte", cut.Find(".fp-review-credited").TextContent);
}
[Fact]
public void Auto_submitted_attempts_are_explained()
{
var cut = Open(Review(auto: true));
Assert.Contains("automatisch abgegeben", cut.Find(".fp-review-auto").TextContent);
Assert.Contains("Nur rechtzeitig gespeicherte Antworten zählen.", cut.Find(".fp-review-auto").TextContent);
}
[Fact]
public void Missing_attempts_and_running_attempts_are_reported()
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptReview>(c => c.Add(x => x.AttemptId, Guid.NewGuid())));
Assert.Contains("Diesen Versuch gibt es nicht (mehr).", cut.Find(".fp-learning-missing").TextContent);
}
[Fact]
public void Rejected_ratings_show_the_server_message()
{
var review = Review();
var cut = Open(review);
Api.Offline = true;
cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-wrong-button")!.Click();
cut.WaitForAssertion(() => Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-dialog-error").TextContent));
}
}
@@ -0,0 +1,486 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Lösen eines Blatts (Übung und Prüfung): Fortschritt, Speichern, Rückmeldung, Hinweise, Abgabe-Bestätigung, Countdown nach Serveruhr, Auto-Abgabe.</summary>
public sealed class AttemptRunnerTests : LearningTestBase
{
private static readonly DateTimeOffset Start = FakeLearningApi.Now;
private static LearningAttemptDto Attempt(LearningMode mode = LearningMode.Practice, DateTimeOffset? deadline = null, params LearningAttemptItemDto[] items)
{
var rows = items.Length > 0
? items
:
[
FakeLearningApi.AttemptItem(0, "Was ist 9 + 3?", LearningAnswerType.Number, 2),
FakeLearningApi.AttemptItem(1, "Nenne ein Haustier.", LearningAnswerType.Text),
FakeLearningApi.AttemptItem(2, "Hauptstadt von Deutschland?", LearningAnswerType.SingleChoice, 1, ["Paris", "Berlin", "Rom"]),
];
return FakeLearningApi.NewAttempt("Rechnen", mode, rows, deadline);
}
private IRenderedComponent<LearningHost> Open(LearningAttemptDto attempt)
{
Api.Attempts[attempt.Id] = attempt;
SignInAs(Roles.Child);
return Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, attempt.Id)));
}
private static string Progress(IRenderedComponent<LearningHost> cut) => cut.Find(".fp-learning-progress-text").TextContent;
// ---------- Übung ----------
[Fact]
public void Shows_the_first_question_with_progress_navigation_and_no_clock_without_a_time_limit()
{
var cut = Open(Attempt());
Assert.Equal("Rechnen", cut.Find(".fp-learning-run-title").TextContent);
Assert.Contains("Übung", cut.Find(".fp-learning-mode").TextContent);
Assert.Equal("0 von 3 beantwortet", Progress(cut));
Assert.Equal("Was ist 9 + 3?", cut.Find(".fp-learning-question").TextContent);
Assert.Contains("Aufgabe 1 von 3", cut.Find(".fp-learning-question-card").TextContent);
Assert.Contains("2 Punkte", cut.Find(".fp-learning-question-card").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-timer"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
Assert.True(cut.Find(".fp-learning-prev").HasAttribute("disabled"));
var dots = cut.FindAll(".fp-learning-dot");
Assert.Equal(3, dots.Count);
Assert.Equal("step", dots[0].GetAttribute("aria-current"));
Assert.Equal("Aufgabe 1, offen", dots[0].GetAttribute("aria-label"));
Assert.Contains("fp-learning-submit", cut.Find(".fp-learning-submit").ClassList);
}
[Fact]
public void Number_answers_are_saved_on_change_with_instant_feedback_in_practice()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12,5");
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId, "12,5")], Api.Saved);
Assert.Contains("Richtig! 🎉", cut.Find(".fp-learning-feedback").TextContent);
Assert.Equal("1 von 3 beantwortet", Progress(cut));
Assert.Equal("Aufgabe 1, beantwortet", cut.FindAll(".fp-learning-dot")[0].GetAttribute("aria-label"));
Assert.Equal("12,5", cut.Find(".fp-learning-number input").GetAttribute("value"));
}
[Fact]
public void Wrong_and_open_verdicts_get_gentle_messages_and_cleared_answers_remove_the_feedback()
{
var attempt = Attempt();
var cut = Open(attempt);
Api.SaveBehavior = (_, _, value) => ApiResult<SaveAnswerResultDto>.Ok(
new SaveAnswerResultDto(attempt.Items[0].ItemId, value is not null, value is null ? null : value == "x" ? LearningVerdict.Open : LearningVerdict.Wrong, null, Start));
cut.Find(".fp-learning-number input").Change("1");
cut.WaitForAssertion(() => Assert.Contains("Nicht ganz – versuch es noch einmal.", cut.Find(".fp-learning-feedback").TextContent));
cut.Find(".fp-learning-number input").Change("x");
cut.WaitForAssertion(() => Assert.Contains("Das schauen sich Mama oder Papa an.", cut.Find(".fp-learning-feedback").TextContent));
cut.Find(".fp-learning-number input").Change(string.Empty);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-learning-feedback")));
Assert.Equal("0 von 3 beantwortet", Progress(cut));
Assert.Equal(["1", "x", null], Api.Saved.Select(s => s.Value));
}
[Fact]
public void Unchanged_values_are_not_saved_again()
{
var cut = Open(Attempt());
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Single(Api.Saved));
cut.Find(".fp-learning-number input").Change(" 12 "); // nur Leerraum geändert
cut.Find(".fp-learning-next").Click();
cut.Find(".fp-learning-prev").Click();
Assert.Single(Api.Saved);
}
[Fact]
public void Hints_are_hidden_until_requested_and_only_shown_when_the_server_sent_them()
{
var attempt = Attempt(items:
[
FakeLearningApi.AttemptItem(0, "Mit Hinweis", hint: "Rechne zuerst die Klammer."),
FakeLearningApi.AttemptItem(1, "Ohne Hinweis"),
]);
var cut = Open(attempt);
Assert.Empty(cut.FindAll(".fp-learning-hint"));
Assert.DoesNotContain("Rechne zuerst die Klammer.", cut.Markup);
cut.Find(".fp-learning-hint-button").Click();
Assert.Contains("Rechne zuerst die Klammer.", cut.Find(".fp-learning-hint").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
cut.Find(".fp-learning-next").Click();
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
Assert.Empty(cut.FindAll(".fp-learning-hint"));
}
[Fact]
public void Choice_answers_are_selected_with_one_tap_and_saved_as_the_option_index()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.FindAll(".fp-learning-dot")[2].Click();
var options = cut.FindAll("button[role='radio']");
Assert.Equal(["AParis", "BBerlin", "CRom"], options.Select(o => string.Concat(Text(o).Where(c => !char.IsWhiteSpace(c)))).ToList());
Assert.All(options, o => Assert.Equal("false", o.GetAttribute("aria-checked")));
options[1].Click();
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll("button[role='radio']")[1].GetAttribute("aria-checked")));
Assert.Equal((attempt.Items[2].ItemId, "1"), (Api.Saved.Last().Item, Api.Saved.Last().Value));
Assert.Equal("1 von 3 beantwortet", Progress(cut));
Assert.Equal("radiogroup", cut.Find(".fp-learning-choices").GetAttribute("role"));
}
[Fact]
public void Navigation_moves_between_questions_and_keeps_the_entered_values()
{
var cut = Open(Attempt());
cut.Find(".fp-learning-number input").Change("12");
cut.Find(".fp-learning-next").Click();
Assert.Contains("Aufgabe 2 von 3", cut.Find(".fp-learning-question-card").TextContent);
Assert.Equal("Nenne ein Haustier.", cut.Find(".fp-learning-question").TextContent);
Assert.False(cut.Find(".fp-learning-prev").HasAttribute("disabled"));
cut.Find(".fp-learning-text textarea").Change("Hund");
cut.FindAll(".fp-learning-dot")[2].Click(); // Sprung zur letzten Aufgabe
Assert.Empty(cut.FindAll(".fp-learning-next")); // letzte Aufgabe: kein „Weiter“
Assert.Equal("step", cut.FindAll(".fp-learning-dot")[2].GetAttribute("aria-current"));
cut.Find(".fp-learning-prev").Click();
Assert.Equal("Hund", cut.Find(".fp-learning-text textarea").TextContent.Trim());
cut.Find(".fp-learning-prev").Click();
Assert.Equal("12", cut.Find(".fp-learning-number input").GetAttribute("value"));
Assert.Equal("2 von 3 beantwortet", Progress(cut));
}
[Fact]
public void Existing_answers_of_a_resumed_attempt_are_restored()
{
var attempt = Attempt(items:
[
FakeLearningApi.AttemptItem(0, "A", answer: "42"),
FakeLearningApi.AttemptItem(1, "B"),
]);
var cut = Open(attempt);
Assert.Equal("42", cut.Find(".fp-learning-number input").GetAttribute("value"));
Assert.Equal("1 von 2 beantwortet", Progress(cut));
}
[Fact]
public void Saves_run_one_after_another_and_the_latest_value_wins()
{
var attempt = Attempt();
var cut = Open(attempt);
var gate = new TaskCompletionSource();
Api.SaveGate = gate;
cut.Find(".fp-learning-number input").Change("1");
cut.Find(".fp-learning-number input").Change("12");
// Die zweite Speicherung wartet auf die erste (keine überholende Anfrage)
Assert.Equal(["1"], Api.Saved.Select(s => s.Value));
gate.SetResult();
cut.WaitForAssertion(() => Assert.Equal(["1", "12"], Api.Saved.Select(s => s.Value)));
cut.WaitForAssertion(() => Assert.Equal("12", Api.Attempts[attempt.Id].Items[0].Answer));
}
[Fact]
public void Failed_saves_are_marked_and_can_be_retried()
{
var attempt = Attempt();
var cut = Open(attempt);
Api.Offline = true;
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Nicht gespeichert", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal("1 von 3 beantwortet", Progress(cut)); // lokal beantwortet, aber noch nicht gespeichert
Api.Offline = false;
cut.Find(".fp-learning-save-failed button").Click();
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal(["12", "12"], Api.Saved.Select(s => s.Value));
}
// ---------- Abgabe ----------
[Fact]
public void Submitting_asks_for_confirmation_mentions_open_questions_and_shows_the_result()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12");
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Wirklich abgeben?", cut.Markup));
Assert.Contains("2 Aufgaben sind noch ohne Antwort.", cut.Markup);
Assert.Empty(Api.Submitted);
cut.FindAll("button").First(b => Text(b) == "Weiterarbeiten").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Wirklich abgeben?", cut.Markup));
Assert.Empty(Api.Submitted);
var again = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Wirklich abgeben?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Ja, abgeben").Click();
cut.WaitForAssertion(() => Assert.Equal([attempt.Id], Api.Submitted));
cut.WaitForAssertion(() => Assert.Contains("Abgegeben – gut gemacht!", cut.Markup));
Assert.Equal("2 Punkte von 4", Text(cut.Find(".fp-learning-score")));
Assert.Empty(cut.FindAll(".fp-learning-runner .fp-learning-question-card"));
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(again.IsCompleted || !again.IsFaulted);
}
[Fact]
public void Submitting_with_everything_answered_does_not_warn_about_open_questions()
{
var attempt = Attempt(items: [FakeLearningApi.AttemptItem(0, "Nur eine Frage")]);
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("1");
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Du hast alle Aufgaben beantwortet.", cut.Markup));
Assert.DoesNotContain("noch ohne Antwort", cut.Markup);
Assert.True(click.IsCompleted || !click.IsFaulted);
}
[Fact]
public void A_submitted_attempt_opened_directly_shows_the_result_with_verdicts_and_only_allowed_solutions()
{
var items = new[]
{
FakeLearningApi.AttemptItem(0, "Was ist 9 + 3?", points: 2, answer: "11",
feedback: new LearningItemFeedbackDto(LearningVerdict.Wrong, 0, null, "12", "Neun plus drei ergibt zwölf.")),
FakeLearningApi.AttemptItem(1, "Hauptstadt?", LearningAnswerType.SingleChoice, 1, ["Paris", "Berlin"], answer: "1",
feedback: new LearningItemFeedbackDto(LearningVerdict.Correct, 1, null, null, null)),
FakeLearningApi.AttemptItem(2, "Aufsatz", LearningAnswerType.Text, 3, answer: "Mein Hund",
feedback: new LearningItemFeedbackDto(LearningVerdict.Partial, 2, "Satzbau beachten", null, null)),
FakeLearningApi.AttemptItem(3, "Offen", LearningAnswerType.Text, 1, answer: "?",
feedback: new LearningItemFeedbackDto(LearningVerdict.Open, 0, null, null, null)),
FakeLearningApi.AttemptItem(4, "Nicht beantwortet", answer: null,
feedback: new LearningItemFeedbackDto(LearningVerdict.Wrong, 0, null, "7", null)),
};
var attempt = Attempt(items: items) with
{
Status = LearningAttemptStatus.Submitted,
Result = new MyLearningResultDto(Guid.NewGuid(), 1, LearningAttemptStatus.Submitted, Start, false, 3, 8, 1, null, false),
};
var cut = Open(attempt);
Assert.Equal("3 Punkte von 8", Text(cut.Find(".fp-learning-score")));
Assert.Contains("1 Antwort wird noch von Mama oder Papa angeschaut", cut.Find(".fp-learning-pending").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-grade"));
var rows = cut.FindAll(".fp-learning-answer");
Assert.Equal(5, rows.Count);
Assert.Equal(["Falsch", "Richtig", "Teilweise richtig (2 von 3)", "Wird geprüft", "Falsch"], rows.Select(r => Text(r.QuerySelector(".fp-learning-verdict")!)).ToList());
Assert.Contains("Deine Antwort: 11", Text(rows[0].QuerySelector(".fp-learning-given")!));
Assert.Contains("Richtige Lösung: 12", Text(rows[0].QuerySelector(".fp-learning-correct")!));
Assert.Contains("Neun plus drei ergibt zwölf.", rows[0].QuerySelector(".fp-learning-explanation")!.TextContent);
Assert.Contains("Deine Antwort: Berlin", Text(rows[1].QuerySelector(".fp-learning-given")!)); // Index 1 → Text der Option
Assert.Null(rows[1].QuerySelector(".fp-learning-correct")); // Lösung nicht freigegeben → nichts anzeigen
Assert.Contains("Kommentar: Satzbau beachten", rows[2].QuerySelector(".fp-learning-reason")!.TextContent);
Assert.Contains("Deine Antwort: keine Antwort", Text(rows[4].QuerySelector(".fp-learning-given")!));
Assert.Contains("Richtige Lösung: 7", Text(rows[4].QuerySelector(".fp-learning-correct")!));
Assert.Empty(Api.Saved);
Assert.Equal("lernen", cut.Find(".fp-learning-back").GetAttribute("href"));
}
[Fact]
public void A_confirmed_exam_result_shows_the_grade()
{
var attempt = Attempt(LearningMode.Exam, items: [FakeLearningApi.AttemptItem(0, "Frage", feedback: new LearningItemFeedbackDto(LearningVerdict.Correct, 1, null, null, null))]) with
{
Status = LearningAttemptStatus.Reviewed,
Result = new MyLearningResultDto(Guid.NewGuid(), 1, LearningAttemptStatus.Reviewed, Start, false, 1, 1, 0, 1, true),
};
var cut = Open(attempt);
Assert.Equal("Note 1 – sehr gut", Text(cut.Find(".fp-learning-grade")));
Assert.Contains("Von Mama oder Papa bestätigt", cut.Find(".fp-learning-final").TextContent);
}
[Fact]
public void Unknown_attempts_are_reported_with_a_way_back()
{
SignInAs(Roles.Child);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, Guid.NewGuid())));
Assert.Contains("Dieses Blatt gibt es nicht oder du hast keinen Zugriff.", cut.Find(".fp-learning-missing").TextContent);
}
[Fact]
public void Load_errors_offer_a_retry()
{
var attempt = Attempt();
Api.Attempts[attempt.Id] = attempt;
Api.Offline = true;
SignInAs(Roles.Child);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, attempt.Id)));
Assert.Contains("Keine Verbindung", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
cut.Find(".fp-learning-error button").Click();
cut.WaitForAssertion(() => Assert.Contains("Was ist 9 + 3?", cut.Markup));
}
// ---------- Prüfung ----------
[Fact]
public void Exam_hides_feedback_and_hints_and_shows_the_countdown()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
var cut = Open(attempt);
Assert.Contains("Prüfung", cut.Find(".fp-learning-mode").TextContent);
Assert.Equal("10:00", cut.Find(".fp-learning-timer-text").TextContent);
Assert.Equal("timer", cut.Find(".fp-learning-timer").GetAttribute("role"));
Assert.Equal(1, Time.ActiveTimersOf<AttemptRunner>());
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Empty(cut.FindAll(".fp-learning-feedback")); // keine Sofort-Rückmeldung
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
Assert.Equal("1 von 3 beantwortet", Progress(cut));
}
[Fact]
public void Countdown_follows_the_server_clock_and_not_the_device_clock()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
Api.ServerNow = () => Start.AddMinutes(2); // das Gerät geht 2 Minuten nach
var cut = Open(attempt);
Assert.Equal("8:00", cut.Find(".fp-learning-timer-text").TextContent);
Time.Advance(TimeSpan.FromMinutes(1));
cut.WaitForAssertion(() => Assert.Equal("7:00", cut.Find(".fp-learning-timer-text").TextContent));
}
[Fact]
public void Countdown_ticks_every_second_turns_urgent_in_the_last_minute_and_announces_the_time()
{
var cut = Open(Attempt(LearningMode.Exam, Start.AddMinutes(10)));
Time.Advance(TimeSpan.FromSeconds(5));
cut.WaitForAssertion(() => Assert.Equal("9:55", cut.Find(".fp-learning-timer-text").TextContent));
Assert.DoesNotContain("is-urgent", cut.Find(".fp-learning-timer").ClassList);
Time.Advance(TimeSpan.FromMinutes(5).Subtract(TimeSpan.FromSeconds(5))); // 5 Minuten verbleibend
cut.WaitForAssertion(() => Assert.Equal("Noch 5 Minuten.", cut.Find(".fp-learning-announce").TextContent));
Time.Advance(TimeSpan.FromSeconds(4 * 60 + 1)); // 59 Sekunden verbleibend
cut.WaitForAssertion(() => Assert.Equal("0:59", cut.Find(".fp-learning-timer-text").TextContent));
Assert.Contains("is-urgent", cut.Find(".fp-learning-timer").ClassList);
Assert.Equal("Noch eine Minute.", cut.Find(".fp-learning-announce").TextContent);
}
[Fact]
public void Time_up_submits_automatically_and_shows_the_result()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
Api.ServerNow = () => Time.GetUtcNow();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12");
Assert.Empty(Api.Submitted);
Time.Advance(TimeSpan.FromMinutes(10).Subtract(TimeSpan.FromSeconds(1)));
Assert.Empty(Api.Submitted); // eine Sekunde vor Ablauf wird noch nichts abgegeben
Time.Advance(TimeSpan.FromSeconds(1));
cut.WaitForAssertion(() => Assert.Equal([attempt.Id], Api.Submitted));
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.Contains("Die Zeit ist abgelaufen – deine Antworten wurden abgegeben.", cut.Markup); // Hinweis (Snackbar)
Assert.Empty(cut.FindAll(".fp-learning-timer"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>()); // Uhr gestoppt
Assert.Empty(cut.FindAll(".fp-learning-question-card"));
}
[Fact]
public void Auto_submit_without_confirmation_and_retry_after_a_network_error()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(1));
Api.ServerNow = () => Time.GetUtcNow();
var cut = Open(attempt);
Api.Offline = true;
Time.Advance(TimeSpan.FromMinutes(1));
cut.WaitForAssertion(() => Assert.NotEmpty(Api.Submitted)); // Versuch läuft, scheitert wegen fehlender Verbindung
Assert.DoesNotContain("Wirklich abgeben?", cut.Markup); // nie eine Rückfrage bei Zeitablauf
Assert.Empty(cut.FindAll(".fp-learning-score"));
var attempts = Api.Submitted.Count;
Api.Offline = false;
Time.Advance(TimeSpan.FromSeconds(1)); // der Timer läuft weiter und versucht es erneut
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.True(Api.Submitted.Count > attempts);
}
[Fact]
public void Saving_after_the_deadline_is_rejected_by_the_server_and_the_result_is_loaded()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(1));
var cut = Open(attempt);
// Der Server hat den Versuch inzwischen abgegeben und lehnt Speicherungen mit 409 ab.
Api.SaveBehavior = (_, _, _) => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 409, Detail = "Die Zeit ist abgelaufen. Der Versuch wurde abgegeben." });
Api.Attempts[attempt.Id] = attempt with
{
Status = LearningAttemptStatus.Submitted,
AutoSubmitted = true,
Result = new MyLearningResultDto(attempt.Id, 1, LearningAttemptStatus.Submitted, Start.AddMinutes(1), true, 0, 4, 0, null, false),
};
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.Contains("Die Zeit ist abgelaufen. Der Versuch wurde abgegeben.", cut.Markup);
Assert.Empty(cut.FindAll(".fp-learning-question-card"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
}
[Fact]
public async Task Disposing_the_runner_stops_the_clock()
{
var cut = Open(Attempt(LearningMode.Exam, Start.AddMinutes(10)));
Assert.Equal(1, Time.ActiveTimersOf<AttemptRunner>());
cut.Render(p => p.AddChildContent("<span>weg</span>"));
await Task.Yield();
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
Time.Advance(TimeSpan.FromMinutes(11)); // würde sonst abgeben
Assert.Empty(Api.Submitted);
}
}
@@ -0,0 +1,215 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Kinderansicht „Lernen“: Karten mit Status, Aktionen je Zustand, Prüfungsstart mit Bestätigung, Ergebnis, Live-Aktualisierung.</summary>
public sealed class KidOverviewTests : LearningTestBase
{
private static readonly DateOnly Today = new(2026, 10, 12);
private static MyLearningSetDto Set(
string title,
LearningSetStatus status = LearningSetStatus.Assigned,
LearningMode mode = LearningMode.Practice,
bool canStart = true,
Guid? running = null,
int used = 0,
int max = 0,
int? timeLimit = null,
DateOnly? due = null,
int pointsCredited = 0,
params MyLearningResultDto[] results) =>
new(Guid.NewGuid(), title, LearningSubjects.Math, 3, due, status, mode, timeLimit, 5, 10, max, used, canStart, running, pointsCredited, results);
private static MyLearningResultDto Result(int earned, int total, bool final, int? grade = null, int open = 0) =>
new(Guid.NewGuid(), 1, final ? LearningAttemptStatus.Reviewed : LearningAttemptStatus.Submitted, FakeLearningApi.Now, false, earned, total, open, grade, final);
private IRenderedComponent<LearningHost> Open()
{
SignInAs(Roles.Child);
return Render<LearningHost>(p => p.AddChildContent<KidOverview>());
}
[Fact]
public void Open_sheets_show_meta_and_the_right_action_per_state()
{
var fresh = Set("Plus und Minus", due: Today, timeLimit: null);
var running = Set("Brüche", LearningSetStatus.InProgress, running: Guid.NewGuid(), used: 1, max: 3);
var exam = Set("Klassenarbeit", mode: LearningMode.Exam, timeLimit: 20, max: 1);
var done = Set("Deutsch-Test", LearningSetStatus.Reviewed, canStart: false, used: 1, max: 1, pointsCredited: 8, results: Result(4, 5, true, 2));
Api.Mine.AddRange([fresh, running, exam, done]);
var cut = Open();
Assert.Equal("Hallo Mia! Hier sind deine Übungsblätter.", cut.Find(".fp-learning-greeting").TextContent);
var open = cut.Find("ul[aria-label='Offene Blätter']").QuerySelectorAll(".fp-learning-card");
Assert.Equal(3, open.Length);
var freshCard = cut.Find($"[data-set-id='{fresh.Id}']");
Assert.Contains("fällig heute", freshCard.TextContent);
Assert.Contains("5 Aufgaben", freshCard.TextContent);
Assert.Equal("Los geht's", Text(freshCard.QuerySelector(".fp-learning-start")!));
Assert.Null(freshCard.QuerySelector(".fp-learning-result-link"));
var runningCard = cut.Find($"[data-set-id='{running.Id}']");
Assert.Equal("Weitermachen", Text(runningCard.QuerySelector(".fp-learning-continue")!));
Assert.Equal($"lernen/versuch/{running.RunningAttemptId}", runningCard.QuerySelector(".fp-learning-continue")!.GetAttribute("href"));
Assert.Contains("Versuch 1 von 3", runningCard.TextContent);
Assert.Null(runningCard.QuerySelector(".fp-learning-start"));
var examCard = cut.Find($"[data-set-id='{exam.Id}']");
Assert.Contains("Prüfung · 20 Min.", examCard.QuerySelector(".fp-learning-mode")!.TextContent);
var finished = cut.Find("ul[aria-label='Abgegebene Blätter']").QuerySelectorAll(".fp-learning-card");
var doneCard = Assert.Single(finished);
Assert.Null(doneCard.QuerySelector(".fp-learning-start"));
Assert.Contains("Geprüft: 4 Punkte von 5 · Note 2 – gut", doneCard.QuerySelector(".fp-learning-result")!.TextContent);
Assert.Contains("8 Punkte gutgeschrieben", doneCard.TextContent);
Assert.Equal($"lernen/versuch/{done.Results[0].AttemptId}", doneCard.QuerySelector(".fp-learning-result-link")!.GetAttribute("href"));
}
[Fact]
public void Submitted_results_are_marked_as_pending_and_repeatable_sheets_offer_another_try()
{
var set = Set("Wiederholen", LearningSetStatus.Submitted, canStart: true, used: 1, max: 3, results: Result(3, 5, false, open: 1));
Api.Mine.Add(set);
var cut = Open();
var card = cut.Find($"[data-set-id='{set.Id}']");
Assert.Contains("Abgegeben: 3 Punkte von 5 (wird von Mama oder Papa geprüft)", card.QuerySelector(".fp-learning-result")!.TextContent);
Assert.Equal("Nochmal versuchen", Text(card.QuerySelector(".fp-learning-start")!));
Assert.Contains("Versuch 2 von 3", card.TextContent);
}
[Fact]
public void Overdue_open_sheets_are_highlighted()
{
var late = Set("Zu spät", due: Today.AddDays(-2));
var fine = Set("Rechtzeitig", due: Today.AddDays(3));
Api.Mine.AddRange([late, fine]);
var cut = Open();
Assert.Contains("is-overdue", cut.Find($"[data-set-id='{late.Id}']").ClassList);
Assert.DoesNotContain("is-overdue", cut.Find($"[data-set-id='{fine.Id}']").ClassList);
Assert.Contains("überfällig seit 10.10.", cut.Find($"[data-set-id='{late.Id}'] .fp-learning-due").TextContent);
// Reihenfolge: früheste Fälligkeit zuerst (Sortierung der Api bleibt erhalten)
var titles = cut.FindAll(".fp-learning-card .fp-learning-title").Select(t => t.TextContent).ToList();
Assert.Contains("Zu spät", titles[0]);
Assert.Contains("Rechtzeitig", titles[1]);
}
[Fact]
public void Starting_a_practice_sheet_creates_an_attempt_and_navigates_to_it()
{
var set = Set("Üben");
Api.Mine.Add(set);
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1+1?")]) with { SetId = set.Id };
Api.Attempts[attempt.Id] = attempt;
var cut = Open();
cut.Find(".fp-learning-start").Click();
Assert.Equal([set.Id], Api.Started);
Assert.EndsWith($"lernen/versuch/{attempt.Id}", Services.GetRequiredService<Microsoft.AspNetCore.Components.NavigationManager>().Uri);
}
[Fact]
public void Starting_an_exam_asks_for_confirmation_and_explains_the_rules()
{
var set = Set("Klassenarbeit", mode: LearningMode.Exam, timeLimit: 25, max: 1);
Api.Mine.Add(set);
var attempt = FakeLearningApi.NewAttempt("Klassenarbeit", LearningMode.Exam, [FakeLearningApi.AttemptItem(0, "1+1?")], FakeLearningApi.Now.AddMinutes(25)) with { SetId = set.Id };
Api.Attempts[attempt.Id] = attempt;
var cut = Open();
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-start").Click());
cut.WaitForAssertion(() => Assert.Contains("Prüfung beginnen?", cut.Markup));
Assert.Contains("Du hast 25 Minuten. Die Uhr startet sofort", cut.Markup);
Assert.Empty(Api.Started); // noch nichts gestartet – die Uhr läuft erst nach der Bestätigung
// „Noch nicht“ bricht ab
cut.FindAll("button").First(b => Text(b) == "Noch nicht").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Prüfung beginnen?", cut.Markup));
Assert.Empty(Api.Started);
// Bestätigen startet den Versuch
var second = cut.InvokeAsync(() => cut.Find(".fp-learning-start").Click());
cut.WaitForAssertion(() => Assert.Contains("Prüfung beginnen?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Prüfung beginnen").Click();
cut.WaitForAssertion(() => Assert.Equal([set.Id], Api.Started));
Assert.EndsWith($"lernen/versuch/{attempt.Id}", Services.GetRequiredService<Microsoft.AspNetCore.Components.NavigationManager>().Uri);
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(second.IsCompleted || !second.IsFaulted);
}
[Fact]
public void A_failed_start_is_reported_and_the_list_reloads()
{
var set = Set("Aufgebraucht");
Api.Mine.Add(set);
var cut = Open();
var before = Api.GetMineCalls;
cut.Find(".fp-learning-start").Click(); // die Fake-Api lehnt ab (kein laufender Versuch)
cut.WaitForAssertion(() => Assert.Contains("Alle erlaubten Versuche sind aufgebraucht.", cut.Markup));
Assert.True(Api.GetMineCalls > before);
}
[Fact]
public void Empty_state_error_state_and_live_updates()
{
var cut = Open();
Assert.Contains("Gerade gibt es kein Übungsblatt für dich.", cut.Find(".fp-learning-empty").TextContent);
// Live: neues Blatt zugewiesen → Liste lädt neu
var set = Set("Neu zugewiesen");
Api.Mine.Add(set);
cut.InvokeAsync(() => Live.RaiseLearningChanged(new LearningChangedDto(set.Id, LearningChangeKind.Assigned)));
cut.WaitForAssertion(() => Assert.NotNull(cut.Find($"[data-set-id='{set.Id}']")));
}
[Fact]
public void Load_errors_offer_a_retry()
{
Api.Offline = true;
var cut = Open();
Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
Api.Mine.Add(Set("Wieder da"));
cut.FindAll(".fp-learning-error button").Single().Click();
cut.WaitForAssertion(() => Assert.Contains("Wieder da", cut.Markup));
}
[Fact]
public void Everything_done_shows_a_friendly_message_without_open_sheets()
{
Api.Mine.Add(Set("Fertig", LearningSetStatus.Reviewed, canStart: false, results: Result(5, 5, true)));
var cut = Open();
Assert.Contains("Alles erledigt", cut.Find(".fp-learning-nothing-open").TextContent);
Assert.Empty(cut.FindAll("ul[aria-label='Offene Blätter']"));
}
[Fact]
public async Task Disposing_unsubscribes_from_live_events()
{
Api.Mine.Add(Set("Eins"));
var cut = Open();
var calls = Api.GetMineCalls;
cut.Render(p => p.AddChildContent("<span>leer</span>")); // ersetzt den Inhalt: KidOverview wird entsorgt
await Live.RaiseLearningChanged(new LearningChangedDto(Guid.NewGuid(), LearningChangeKind.Assigned));
Assert.Equal(calls, Api.GetMineCalls);
}
}
@@ -0,0 +1,438 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Services;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>
/// TimeProvider mit von Hand steuerbarer Zeit <b>und</b> Timern: <see cref="Advance"/> lässt die Uhr in Sekundenschritten laufen und löst fällige Timer aus.
/// So lässt sich der Prüfungs-Countdown ohne echtes Warten testen.
/// </summary>
internal sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
{
private readonly List<ManualTimer> _timers = [];
private DateTimeOffset _now = start;
/// <summary>Laufende Timer der Komponente <typeparamref name="T"/> (MudBlazor legt eigene Timer an, die hier nicht zählen).</summary>
public int ActiveTimersOf<T>() => _timers.Count(t => !t.Disposed && t.Callback.Target is T);
public override DateTimeOffset GetUtcNow() => _now;
public override TimeZoneInfo LocalTimeZone => FamilyPlanner.Domain.Common.FamilyTime.Berlin;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(callback, state, _now + dueTime, period);
_timers.Add(timer);
return timer;
}
/// <summary>Uhr um <paramref name="by"/> vorstellen; Timer feuern in der richtigen Reihenfolge (Schrittweite höchstens 1 s).</summary>
public void Advance(TimeSpan by)
{
var target = _now + by;
while (_now < target)
{
var step = target - _now > TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : target - _now;
_now += step;
foreach (var timer in _timers.Where(t => !t.Disposed).ToList())
{
timer.FireIfDue(_now);
}
}
}
private sealed class ManualTimer(TimerCallback timerCallback, object? state, DateTimeOffset next, TimeSpan period) : ITimer
{
private DateTimeOffset _next = next;
public TimerCallback Callback { get; } = timerCallback;
public bool Disposed { get; private set; }
public void FireIfDue(DateTimeOffset now)
{
while (!Disposed && _next <= now)
{
_next = period > TimeSpan.Zero ? _next + period : DateTimeOffset.MaxValue;
Callback(state);
}
}
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
public void Dispose() => Disposed = true;
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}
/// <summary>In-Memory-„Server“ der Lernaufgaben für bUnit: Eltern- und Kinderseite, mit Aufrufprotokoll.</summary>
internal sealed class FakeLearningApi : ILearningApi
{
public static readonly Guid Me = Guid.Parse("00000000-0000-0000-0000-0000000000a1");
public static readonly Guid Mia = Guid.Parse("00000000-0000-0000-0000-0000000000a2");
public static readonly Guid Ben = Guid.Parse("00000000-0000-0000-0000-0000000000a3");
public static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
// ---- Eltern ----
public List<LearningSetDto> Sets { get; } = [];
public Dictionary<Guid, LearningSetDetailDto> Details { get; } = [];
public List<LearningReviewQueueItemDto> Queue { get; } = [];
public Dictionary<Guid, LearningAttemptReviewDto> Reviews { get; } = [];
public List<UserDirectoryEntry> Users { get; } =
[
new(Me, "Papa", "#1E88E5", null, Roles.Family),
new(Mia, "Mia", "#E53935", null, Roles.Child),
new(Ben, "Ben", "#43A047", null, Roles.Child),
];
// ---- Kind ----
public List<MyLearningSetDto> Mine { get; } = [];
public Dictionary<Guid, LearningAttemptDto> Attempts { get; } = [];
// ---- Verhalten ----
public bool Offline { get; set; }
/// <summary>Antwort der Api auf das Speichern einer Antwort; Standard: gespeichert, Übung → „richtig“.</summary>
public Func<Guid, Guid, string?, ApiResult<SaveAnswerResultDto>>? SaveBehavior { get; set; }
/// <summary>Antwort der Api auf die Abgabe; Standard: Versuch als abgegeben markieren.</summary>
public Func<Guid, ApiResult<LearningAttemptDto>>? SubmitBehavior { get; set; }
/// <summary>Serveruhr (für ServerNow der Antworten).</summary>
public Func<DateTimeOffset> ServerNow { get; set; } = () => Now;
// ---- Protokoll ----
public List<CreateLearningSetRequest> Created { get; } = [];
public List<(Guid Id, UpdateLearningSetRequest Request)> Updated { get; } = [];
public List<Guid> Deleted { get; } = [];
public List<Guid> Assigned { get; } = [];
public List<Guid> Unassigned { get; } = [];
public List<(Guid Set, LearningItemInput Item)> AddedItems { get; } = [];
public List<(Guid Set, Guid Item, UpdateLearningItemRequest Request)> UpdatedItems { get; } = [];
public List<(Guid Set, Guid Item)> DeletedItems { get; } = [];
public List<(Guid Set, IReadOnlyList<Guid> Order)> Reordered { get; } = [];
public List<(Guid Attempt, Guid Item, ReviewAnswerRequest Request)> Reviewed { get; } = [];
public List<(Guid Attempt, ConfirmAttemptRequest Request)> Confirmed { get; } = [];
public List<Guid> Reopened { get; } = [];
public List<Guid> Started { get; } = [];
public List<(Guid Attempt, Guid Item, string? Value)> Saved { get; } = [];
public List<Guid> Submitted { get; } = [];
public int GetAttemptCalls { get; private set; }
public int GetMineCalls { get; private set; }
public int GetSetsCalls { get; private set; }
private int _version;
// ---------- Aufbau ----------
public static LearningItemDto Item(int order, string question, LearningAnswerType type = LearningAnswerType.Number, string? correct = "12", int points = 1,
IReadOnlyList<string>? options = null, string? hint = null, string? explanation = null) =>
new(Guid.NewGuid(), order, question, type, correct, null, options ?? [], hint, explanation, 1, points, [1]);
public LearningSetDetailDto AddSet(
string title,
LearningSetStatus status = LearningSetStatus.Draft,
LearningMode mode = LearningMode.Practice,
Guid? child = null,
int attempts = 0,
int pending = 0,
int itemCount = 2,
int? timeLimit = null,
int totalPoints = 10,
DateOnly? due = null,
string subject = LearningSubjects.Math)
{
var items = Enumerable.Range(0, itemCount).Select(i => Item(i, $"Frage {i + 1} von {title}")).ToList();
var set = new LearningSetDto(
Guid.NewGuid(), title, subject, 3, child ?? Mia, due, status, mode, 0, timeLimit, true, false, totalPoints, null, LearningSource.Manual, null, null, null,
status == LearningSetStatus.Draft ? null : Now, itemCount, attempts, pending, 0, Now, Now, [1]);
var detail = new LearningSetDetailDto(set, items, []);
Sets.Add(set);
Details[set.Id] = detail;
return detail;
}
public LearningReviewQueueItemDto AddQueueEntry(LearningSetDto set, string child, int open = 0, bool auto = false)
{
var entry = new LearningReviewQueueItemDto(Guid.NewGuid(), set.Id, set.Title, set.Subject, set.Mode, set.TargetUserId, child, 1, Now.AddMinutes(-5), auto, open, 3, 4);
Queue.Add(entry);
return entry;
}
// ---------- Eltern ----------
public Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default)
{
GetSetsCalls++;
return Result(() => Sets.ToList());
}
public Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default) =>
Details.TryGetValue(id, out var detail)
? Result(() => detail)
: Task.FromResult(ApiResult<LearningSetDetailDto>.Fail(new ApiProblem { Status = 404 }));
public Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default)
{
Created.Add(request);
var detail = AddSet(request.Title, mode: request.Mode, child: request.TargetUserId, itemCount: 0, timeLimit: request.TimeLimitMinutes, totalPoints: request.TotalPoints);
return Result(() => detail);
}
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default)
{
Updated.Add((id, request));
var detail = Details[id];
if (request.Version is { } version && !version.SequenceEqual(detail.Set.Version))
{
return Task.FromResult(new ApiResult<LearningSetDetailDto>(detail, new ApiProblem { Status = 409, Detail = "Das Blatt wurde inzwischen von jemand anderem geändert." }));
}
var set = detail.Set with
{
Title = request.Title, Subject = request.Subject, GradeLevel = request.GradeLevel, TargetUserId = request.TargetUserId, DueDate = request.DueDate,
Mode = request.Mode, MaxAttempts = request.MaxAttempts, TimeLimitMinutes = request.TimeLimitMinutes, ShowSolutionsAfterSubmit = request.ShowSolutionsAfterSubmit,
ShuffleItems = request.ShuffleItems, TotalPoints = request.TotalPoints, GradeThresholds = request.GradeThresholds, Version = NextVersion(),
};
return Store(detail with { Set = set });
}
public Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Sets.RemoveAll(s => s.Id == id);
Details.Remove(id);
return Result(() => true);
}
public Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default)
{
Assigned.Add(id);
var detail = Details[id];
return Store(detail with { Set = detail.Set with { Status = LearningSetStatus.Assigned, AssignedAt = Now, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default)
{
Unassigned.Add(id);
var detail = Details[id];
return Store(detail with { Set = detail.Set with { Status = LearningSetStatus.Draft, AssignedAt = null, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default)
{
AddedItems.Add((setId, request));
var detail = Details[setId];
var item = new LearningItemDto(Guid.NewGuid(), detail.Items.Count, request.Question, request.AnswerType, request.CorrectAnswer, request.NumberTolerance,
request.Options ?? [], request.Hint, request.Explanation, request.Difficulty, request.Points, NextVersion());
return Store(detail with { Items = [.. detail.Items, item], Set = detail.Set with { ItemCount = detail.Items.Count + 1, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default)
{
UpdatedItems.Add((setId, itemId, request));
var detail = Details[setId];
var items = detail.Items.Select(i => i.Id != itemId ? i : i with
{
Question = request.Question, AnswerType = request.AnswerType, CorrectAnswer = request.CorrectAnswer, NumberTolerance = request.NumberTolerance,
Options = request.Options ?? [], Hint = request.Hint, Explanation = request.Explanation, Difficulty = request.Difficulty, Points = request.Points, Version = NextVersion(),
}).ToList();
return Store(detail with { Items = items, Set = detail.Set with { Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default)
{
DeletedItems.Add((setId, itemId));
var detail = Details[setId];
var items = detail.Items.Where(i => i.Id != itemId).Select((i, index) => i with { SortOrder = index }).ToList();
return Store(detail with { Items = items, Set = detail.Set with { ItemCount = items.Count, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default)
{
Reordered.Add((setId, itemIds));
var detail = Details[setId];
var items = itemIds.Select((id, index) => detail.Items.Single(i => i.Id == id) with { SortOrder = index }).ToList();
return Store(detail with { Items = items, Set = detail.Set with { Version = NextVersion() } });
}
public Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default) => Result(() => Queue.ToList());
public Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default) =>
Reviews.TryGetValue(attemptId, out var review)
? Result(() => review)
: Task.FromResult(ApiResult<LearningAttemptReviewDto>.Fail(new ApiProblem { Status = 404 }));
public Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default)
{
Reviewed.Add((attemptId, itemId, request));
var review = Reviews[attemptId];
var answers = review.Answers.Select(a => a.ItemId != itemId ? a : a with
{
Verdict = request.Verdict,
Grader = LearningGrader.Parent,
Reason = request.Reason,
AwardedPoints = request.Verdict switch { LearningVerdict.Correct => a.ItemPoints, LearningVerdict.Partial => request.AwardedPoints ?? 1, _ => 0 },
}).ToList();
var score = LearningRules.Score(answers.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
var updated = review with { Answers = answers, EarnedPoints = score.Earned, OpenCount = score.OpenCount };
Reviews[attemptId] = updated;
return Result(() => updated);
}
public Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default)
{
Confirmed.Add((attemptId, request));
var review = Reviews[attemptId] with { Status = LearningAttemptStatus.Reviewed, ReviewNote = request.Note, PointsCredited = 7 };
Reviews[attemptId] = review;
Queue.RemoveAll(q => q.AttemptId == attemptId);
return Result(() => new LearningReviewResultDto(review, 7, 7));
}
public Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default)
{
Reopened.Add(attemptId);
var review = Reviews[attemptId] with { Status = LearningAttemptStatus.Submitted, PointsCredited = 0 };
Reviews[attemptId] = review;
return Result(() => new LearningReviewResultDto(review, -7, 0));
}
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) => Result(() => Users.ToList());
// ---------- Kind ----------
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default)
{
GetMineCalls++;
return Result(() => Mine.ToList());
}
public Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default)
{
Started.Add(setId);
var attempt = Attempts.Values.FirstOrDefault(a => a.SetId == setId && a.Status == LearningAttemptStatus.InProgress);
return attempt is null
? Task.FromResult(ApiResult<LearningAttemptDto>.Fail(new ApiProblem { Status = 409, Detail = "Alle erlaubten Versuche sind aufgebraucht." }))
: Result(() => attempt with { ServerNow = ServerNow() });
}
public Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default)
{
GetAttemptCalls++;
return Attempts.TryGetValue(attemptId, out var attempt)
? Result(() => attempt with { ServerNow = ServerNow() })
: Task.FromResult(ApiResult<LearningAttemptDto>.Fail(new ApiProblem { Status = 404 }));
}
/// <summary>Hält die nächste Speicherung an, bis das Gate abgeschlossen wird (Reihenfolge-Tests).</summary>
public TaskCompletionSource? SaveGate { get; set; }
public async Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default)
{
Saved.Add((attemptId, itemId, request.Value));
if (SaveGate is { } gate)
{
SaveGate = null;
await gate.Task;
}
if (Offline)
{
return ApiResult<SaveAnswerResultDto>.Fail(ApiProblem.Offline);
}
if (SaveBehavior is { } behavior)
{
return behavior(attemptId, itemId, request.Value);
}
var attempt = Attempts[attemptId];
LearningVerdict? verdict = attempt.Mode == LearningMode.Practice && request.Value is not null ? LearningVerdict.Correct : null;
Attempts[attemptId] = attempt with { Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = request.Value } : i).ToList() };
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, request.Value is not null, verdict, attempt.DeadlineAt, ServerNow()));
}
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default)
{
Submitted.Add(attemptId);
if (Offline)
{
return Task.FromResult(ApiResult<LearningAttemptDto>.Fail(ApiProblem.Offline));
}
if (SubmitBehavior is { } behavior)
{
return Task.FromResult(behavior(attemptId));
}
var attempt = Attempts[attemptId];
var items = attempt.Items.Select(i => i with { Feedback = new LearningItemFeedbackDto(i.Answer is null ? LearningVerdict.Wrong : LearningVerdict.Correct, i.Answer is null ? 0 : i.Points, null, null, null) }).ToList();
var earned = items.Sum(i => i.Feedback!.AwardedPoints);
var submitted = attempt with
{
Status = LearningAttemptStatus.Submitted,
Items = items,
AutoSubmitted = attempt.DeadlineAt is { } d && ServerNow() >= d,
Result = new MyLearningResultDto(attempt.Id, attempt.Number, LearningAttemptStatus.Submitted, ServerNow(), false, earned, attempt.Items.Sum(i => i.Points), 0, null, false),
};
Attempts[attemptId] = submitted;
return Result(() => submitted);
}
// ---------- Hilfen ----------
public static LearningAttemptDto NewAttempt(
string title, LearningMode mode, IReadOnlyList<LearningAttemptItemDto> items, DateTimeOffset? deadline = null, LearningAttemptStatus status = LearningAttemptStatus.InProgress) =>
new(Guid.NewGuid(), Guid.NewGuid(), title, LearningSubjects.Math, mode, 1, status, Now, deadline, Now, false, items, null, [1]);
public static LearningAttemptItemDto AttemptItem(int position, string question, LearningAnswerType type = LearningAnswerType.Number, int points = 1,
IReadOnlyList<string>? options = null, string? hint = null, string? answer = null, LearningItemFeedbackDto? feedback = null) =>
new(Guid.NewGuid(), position, question, type, options ?? [], points, 1, answer, hint, feedback);
private Task<ApiResult<LearningSetDetailDto>> Store(LearningSetDetailDto detail)
{
Details[detail.Set.Id] = detail;
var index = Sets.FindIndex(s => s.Id == detail.Set.Id);
if (index >= 0)
{
Sets[index] = detail.Set;
}
return Result(() => detail);
}
private byte[] NextVersion() => BitConverter.GetBytes(++_version + 100);
private Task<ApiResult<T>> Result<T>(Func<T> produce) =>
Task.FromResult(Offline ? ApiResult<T>.Fail(ApiProblem.Offline) : ApiResult<T>.Ok(produce()));
}
@@ -0,0 +1,104 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
public sealed class LearningFormatTests
{
[Theory]
[InlineData(0, "0:00")]
[InlineData(-5, "0:00")]
[InlineData(1, "0:01")]
[InlineData(59, "0:59")]
[InlineData(60, "1:00")]
[InlineData(61, "1:01")]
[InlineData(600, "10:00")]
[InlineData(3599, "59:59")]
[InlineData(3600, "1:00:00")]
[InlineData(5025, "1:23:45")]
public void Countdown_shows_minutes_and_seconds(int seconds, string expected) =>
Assert.Equal(expected, LearningFormat.Countdown(TimeSpan.FromSeconds(seconds)));
[Fact]
public void Countdown_rounds_partial_seconds_up_so_the_clock_never_shows_zero_early()
{
Assert.Equal("0:01", LearningFormat.Countdown(TimeSpan.FromMilliseconds(100)));
Assert.Equal("1:00", LearningFormat.Countdown(TimeSpan.FromMilliseconds(59_100)));
}
[Theory]
[InlineData(300, "Noch 5 Minuten.")]
[InlineData(150, "Noch 3 Minuten.")]
[InlineData(120, "Noch 2 Minuten.")]
[InlineData(119, "Noch eine Minute.")]
[InlineData(60, "Noch eine Minute.")]
[InlineData(59, "Noch 59 Sekunden.")]
[InlineData(10, "Noch 10 Sekunden.")]
[InlineData(0, "Noch 0 Sekunden.")]
public void Remaining_time_is_spoken_in_german(int seconds, string expected) =>
Assert.Equal(expected, LearningFormat.RemainingSpoken(TimeSpan.FromSeconds(seconds)));
[Theory]
[InlineData(0, "fällig heute")]
[InlineData(1, "fällig morgen")]
[InlineData(3, "fällig Do., 15.10.")]
[InlineData(-1, "überfällig seit 11.10.")]
[InlineData(-30, "überfällig seit 12.09.")]
[InlineData(100, "fällig 20.01.2027")]
public void Due_dates_are_relative_to_today(int offsetDays, string expected)
{
var today = new DateOnly(2026, 10, 12);
Assert.Equal(expected, LearningFormat.Due(today.AddDays(offsetDays), today));
Assert.Equal(string.Empty, LearningFormat.Due(null, today));
}
[Fact]
public void Overdue_is_strictly_before_today()
{
var today = new DateOnly(2026, 10, 12);
Assert.False(LearningFormat.IsOverdue(today, today));
Assert.True(LearningFormat.IsOverdue(today.AddDays(-1), today));
Assert.False(LearningFormat.IsOverdue(null, today));
}
[Fact]
public void Times_are_shown_in_the_family_time_zone()
{
Assert.Equal("12.10.2026 09:55", LearningFormat.Local(new DateTimeOffset(2026, 10, 12, 7, 55, 0, TimeSpan.Zero))); // Sommerzeit
Assert.Equal("12.01.2027 08:55", LearningFormat.Local(new DateTimeOffset(2027, 1, 12, 7, 55, 0, TimeSpan.Zero))); // Winterzeit
}
[Theory]
[InlineData(LearningAnswerType.SingleChoice, "1", "Berlin")]
[InlineData(LearningAnswerType.SingleChoice, "7", "7")]
[InlineData(LearningAnswerType.SingleChoice, "x", "x")]
[InlineData(LearningAnswerType.Number, "1", "1")]
[InlineData(LearningAnswerType.Text, " ", "keine Antwort")]
[InlineData(LearningAnswerType.Text, null, "keine Antwort")]
public void Answers_are_readable(LearningAnswerType type, string? value, string expected) =>
Assert.Equal(expected, LearningFormat.AnswerText(type, ["Paris", "Berlin"], value));
[Fact]
public void Texts_are_german_and_complete()
{
foreach (var status in Enum.GetValues<LearningSetStatus>())
{
Assert.NotEqual(status.ToString(), LearningFormat.Status(status));
}
Assert.Equal(["Richtig", "Teilweise richtig", "Falsch", "Wird geprüft"], new[] { LearningVerdict.Correct, LearningVerdict.Partial, LearningVerdict.Wrong, LearningVerdict.Open }.Select(LearningFormat.Verdict));
Assert.Equal(["Übung", "Prüfung"], new[] { LearningMode.Practice, LearningMode.Exam }.Select(LearningFormat.Mode));
Assert.Equal(["Leicht", "Mittel", "Schwer"], new[] { 1, 2, 3 }.Select(LearningFormat.Difficulty));
Assert.Equal("1 Punkt", LearningFormat.Points(1));
Assert.Equal("3 Punkte", LearningFormat.Points(3));
Assert.Equal("Note 2 – gut", LearningFormat.Grade(2));
Assert.Equal(["beliebig oft", "1 Versuch", "3 Versuche"], new[] { 0, 1, 3 }.Select(LearningFormat.Attempts));
Assert.Equal("3. Klasse", LearningFormat.GradeLevel(3));
Assert.Equal("ohne Zeitlimit", LearningFormat.TimeLimit(null));
Assert.Equal("20 Min.", LearningFormat.TimeLimit(20));
Assert.Contains("Du hast 20 Minuten", LearningFormat.ExamRules(20));
Assert.DoesNotContain("Minuten", LearningFormat.ExamRules(null));
}
}
@@ -0,0 +1,10 @@
@* Testhülle: MudBlazor-Provider (Dialoge, Meldungen, Menüs) um eine beliebige Komponente. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@ChildContent
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,127 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Web.Client.Pages;
using LearningPage = FamilyPlanner.Web.Client.Pages.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>
/// Die Seiten wählen die Ansicht nach den wirksamen Berechtigungen (<c>LearningManage</c>/<c>LearningSolve</c>) und rufen ohne Recht keine Api auf.
/// Erzwungen wird serverseitig (Integrationstests) – das hier prüft, dass die Oberfläche nichts Falsches anbietet.
/// </summary>
public sealed class LearningPagesTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open<TPage>(string role, Action<ComponentParameterCollectionBuilder<TPage>>? parameters = null)
where TPage : Microsoft.AspNetCore.Components.IComponent
{
SignInAs(role, "Test", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<TPage>(parameters));
}
[Theory]
[InlineData(Roles.Admin)]
[InlineData(Roles.Family)]
public void Parents_see_the_management_view(string role)
{
var cut = Open<LearningPage>(role);
Assert.NotEmpty(cut.FindAll(".fp-learning-parent"));
Assert.Empty(cut.FindAll(".fp-learning-kid"));
Assert.NotEmpty(cut.FindAll(".fp-learning-new"));
Assert.Equal(0, Api.GetMineCalls);
}
[Fact]
public void Children_see_only_their_own_sheets_and_no_management_controls()
{
Api.AddSet("Nur für Eltern sichtbar", LearningSetStatus.Assigned);
var cut = Open<LearningPage>(Roles.Child);
Assert.NotEmpty(cut.FindAll(".fp-learning-kid"));
Assert.Empty(cut.FindAll(".fp-learning-parent"));
Assert.Empty(cut.FindAll(".fp-learning-new"));
Assert.Empty(cut.FindAll(".fp-learning-tab-review"));
Assert.Equal(0, Api.GetSetsCalls); // Kinder rufen die Verwaltungs-Api nicht auf
Assert.DoesNotContain("Nur für Eltern sichtbar", cut.Markup);
}
[Fact]
public void Guests_get_a_short_message_and_no_api_calls()
{
var cut = Open<LearningPage>(Roles.Guest);
Assert.Contains("nicht freigeschaltet", cut.Find(".fp-learning-no-access").TextContent);
Assert.Equal((0, 0), (Api.GetSetsCalls, Api.GetMineCalls));
}
[Theory]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public void Editor_and_review_pages_are_parent_only(string role)
{
var detail = Api.AddSet("Geheim");
var editor = Open<LearningSetPage>(role, p => p.Add(x => x.SetId, detail.Set.Id));
Assert.Contains("nur Eltern", editor.Find(".fp-learning-no-access").TextContent);
Assert.Empty(editor.FindAll(".fp-learning-editor"));
Assert.DoesNotContain("Geheim", editor.Markup);
}
[Theory]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public void Review_page_is_parent_only(string role)
{
var cut = Open<LearningReviewPage>(role, p => p.Add(x => x.AttemptId, Guid.NewGuid()));
Assert.Contains("nur Eltern", cut.Find(".fp-learning-no-access").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-review"));
}
[Fact]
public void Attempt_page_is_for_solvers_and_not_for_guests()
{
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1 + 1?")]);
Api.Attempts[attempt.Id] = attempt;
var asChild = Open<LearningAttemptPage>(Roles.Child, p => p.Add(x => x.AttemptId, attempt.Id));
Assert.NotEmpty(asChild.FindAll(".fp-learning-runner"));
Assert.Contains("1 + 1?", asChild.Markup);
}
[Fact]
public void Guests_never_reach_the_runner()
{
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1 + 1?")]);
Api.Attempts[attempt.Id] = attempt;
var cut = Open<LearningAttemptPage>(Roles.Guest, p => p.Add(x => x.AttemptId, attempt.Id));
Assert.NotEmpty(cut.FindAll(".fp-learning-no-access"));
Assert.Empty(cut.FindAll(".fp-learning-runner"));
Assert.Equal(0, Api.GetAttemptCalls);
}
[Fact]
public void New_sheet_route_opens_an_empty_editor_for_parents()
{
var cut = Open<LearningSetPage>(Roles.Family);
Assert.Equal("Neues Übungsblatt", cut.Find("h1").TextContent);
}
[Theory]
[InlineData(Roles.Admin, true, true)]
[InlineData(Roles.Family, true, true)]
[InlineData(Roles.Child, false, true)]
[InlineData(Roles.Guest, false, false)]
public void Access_helpers_follow_the_permission_matrix(string role, bool manage, bool solve)
{
var user = new FamilyPlanner.Shared.Auth.CurrentUserDto(
Guid.NewGuid(), "x", "X", null, "#000000", [role], false, null, null, FamilyPlanner.Domain.Authorization.PermissionMatrix.Effective([role]));
Assert.Equal((manage, solve), (FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanManage(user), FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanSolve(user)));
Assert.False(FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanManage(null));
Assert.False(FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanSolve(null));
}
}
@@ -0,0 +1,37 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Web.Tests.Tasks;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Gemeinsamer Aufbau der Lern-Tests: Fake-Api, Live-Verbindung, steuerbare Zeit und Anmeldung mit Rolle.</summary>
public abstract class LearningTestBase : BunitContext
{
internal FakeLearningApi Api { get; } = new();
internal FakeLiveConnection Live { get; } = new();
internal ManualTimeProvider Time { get; } = new(FakeLearningApi.Now);
protected LearningTestBase()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<ILearningApi>(Api);
Services.AddSingleton<ILiveConnection>(Live);
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(Time);
}
protected void SignInAs(string role, string name = "Mia", Guid? id = null) =>
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
id ?? FakeLearningApi.Mia, "mia", name, null, "#E53935", [role], false, null, null, PermissionMatrix.Effective([role])));
protected static string Text(AngleSharp.Dom.IElement element) => element.TextContent.Replace(' ', ' ').Trim();
}
@@ -0,0 +1,154 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Elternansicht „Lernen“: Blätter verwalten, Prüf-Warteschlange, Löschen mit Bestätigung, Live-Aktualisierung.</summary>
public sealed class ParentOverviewTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open(string role = Roles.Family)
{
SignInAs(role, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
}
[Fact]
public void Sets_are_listed_with_status_child_mode_counts_and_pending_reviews()
{
var draft = Api.AddSet("Entwurf Mathe", LearningSetStatus.Draft, itemCount: 3);
var exam = Api.AddSet("Klassenarbeit", LearningSetStatus.Submitted, LearningMode.Exam, FakeLearningApi.Ben, attempts: 1, pending: 1, timeLimit: 30, due: new DateOnly(2026, 10, 14));
Api.AddSet("Diktat", LearningSetStatus.Reviewed, subject: LearningSubjects.German, attempts: 2);
var cut = Open();
var rows = cut.FindAll(".fp-learning-list-item");
Assert.Equal(3, rows.Count);
var draftRow = cut.Find($"[data-set-id='{draft.Set.Id}']");
Assert.Equal("Entwurf", Text(draftRow.QuerySelector(".fp-learning-status")!));
Assert.Contains("Mia", draftRow.TextContent);
Assert.Contains("3 Aufgaben", draftRow.TextContent);
Assert.Equal($"lernen/blatt/{draft.Set.Id}", draftRow.QuerySelector("a.fp-learning-title")!.GetAttribute("href"));
var examRow = cut.Find($"[data-set-id='{exam.Set.Id}']");
Assert.Equal("Abgegeben", Text(examRow.QuerySelector(".fp-learning-status")!));
Assert.Contains("Ben", examRow.TextContent);
Assert.Contains("Prüfung · 30 Min.", examRow.TextContent);
Assert.Contains("1 zu prüfen", examRow.QuerySelector(".fp-learning-pending")!.TextContent);
Assert.Contains("fällig Mi., 14.10.", examRow.TextContent);
Assert.Contains("Blätter (3)", cut.Find(".fp-learning-tab-sets").TextContent);
Assert.Equal("lernen/neu", cut.Find(".fp-learning-new").GetAttribute("href"));
}
[Fact]
public void Empty_state_invites_to_create_the_first_sheet()
{
var cut = Open();
Assert.Contains("Noch keine Übungsblätter. Lege das erste Blatt an.", cut.Find(".fp-learning-empty").TextContent);
Assert.Contains("Zu prüfen (0)", cut.Find(".fp-learning-tab-review").TextContent);
}
[Fact]
public void Review_queue_shows_submissions_with_open_answers_and_time_expiry()
{
var set = Api.AddSet("Mathe-Test", LearningSetStatus.Submitted, attempts: 2, pending: 2).Set;
var openEntry = Api.AddQueueEntry(set, "Mia", open: 2);
var autoEntry = Api.AddQueueEntry(set, "Ben", auto: true);
var cut = Open();
Assert.Contains("Zu prüfen (2)", cut.Find(".fp-learning-tab-review").TextContent);
cut.Find(".fp-learning-tab-review").Click();
var items = cut.FindAll(".fp-learning-queue-item");
Assert.Equal(2, items.Count);
Assert.Contains("Mia – Mathe-Test", items[0].TextContent);
Assert.Contains("2 Antworten offen", items[0].QuerySelector(".fp-learning-open")!.TextContent);
Assert.Equal($"lernen/pruefen/{openEntry.AttemptId}", items[0].QuerySelector(".fp-learning-review-button")!.GetAttribute("href"));
Assert.Contains("3 Punkte von 4", items[0].TextContent);
Assert.Contains("abgegeben 12.10.2026 09:55", items[0].TextContent); // 07:55 UTC = 09:55 Europe/Berlin
Assert.Empty(items[0].QuerySelectorAll(".fp-learning-auto"));
Assert.Contains("Zeit abgelaufen", items[1].QuerySelector(".fp-learning-auto")!.TextContent);
Assert.Equal($"lernen/pruefen/{autoEntry.AttemptId}", items[1].QuerySelector(".fp-learning-review-button")!.GetAttribute("href"));
}
[Fact]
public void Empty_review_queue_is_celebrated()
{
var cut = Open();
cut.Find(".fp-learning-tab-review").Click();
Assert.Contains("Nichts zu prüfen", cut.Find(".fp-learning-queue-empty").TextContent);
}
[Fact]
public void Deleting_asks_for_confirmation_and_removes_the_sheet()
{
var keep = Api.AddSet("Bleibt");
var remove = Api.AddSet("Weg damit", LearningSetStatus.Assigned, attempts: 1);
var cut = Open();
var click = cut.InvokeAsync(() => cut.Find($"[data-set-id='{remove.Set.Id}'] .fp-learning-delete").Click());
cut.WaitForAssertion(() => Assert.Contains("Blatt löschen?", cut.Markup));
Assert.Contains("„Weg damit“ wirklich löschen?", cut.Markup);
Assert.Contains("Bereits vergebene Punkte bleiben im Punktebuch bestehen.", cut.Markup); // es gibt schon Versuche
cut.FindAll("button").First(b => Text(b) == "Abbrechen").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Blatt löschen?", cut.Markup));
Assert.Empty(Api.Deleted);
var again = cut.InvokeAsync(() => cut.Find($"[data-set-id='{remove.Set.Id}'] .fp-learning-delete").Click());
cut.WaitForAssertion(() => Assert.Contains("Blatt löschen?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Löschen").Click();
cut.WaitForAssertion(() => Assert.Equal([remove.Set.Id], Api.Deleted));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll($"[data-set-id='{remove.Set.Id}']")));
Assert.NotEmpty(cut.FindAll($"[data-set-id='{keep.Set.Id}']"));
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(again.IsCompleted || !again.IsFaulted);
}
[Fact]
public void Live_changes_reload_the_lists()
{
var cut = Open();
var before = Api.GetSetsCalls;
var set = Api.AddSet("Neu von Mama", LearningSetStatus.Submitted, pending: 1).Set;
Api.AddQueueEntry(set, "Mia");
cut.InvokeAsync(() => Live.RaiseLearningChanged(new LearningChangedDto(set.Id, LearningChangeKind.Submitted)));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll($"[data-set-id='{set.Id}']")));
Assert.True(Api.GetSetsCalls > before);
Assert.Contains("Zu prüfen (1)", cut.Find(".fp-learning-tab-review").TextContent);
}
[Fact]
public void Load_errors_offer_a_retry()
{
Api.Offline = true;
var cut = Open();
Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
Api.AddSet("Zurück");
cut.Find(".fp-learning-error button").Click();
cut.WaitForAssertion(() => Assert.Contains("Zurück", cut.Markup));
}
[Fact]
public void Overdue_open_sheets_are_flagged_for_parents_too()
{
var today = new DateOnly(2026, 10, 12);
Api.AddSet("Zu spät", LearningSetStatus.Assigned, due: today.AddDays(-1));
Api.AddSet("Alt aber geprüft", LearningSetStatus.Reviewed, due: today.AddDays(-5));
var cut = Open();
Assert.Single(cut.FindAll(".fp-learning-overdue"));
Assert.Contains("überfällig seit 11.10.", cut.Find(".fp-learning-overdue").TextContent);
}
}
@@ -0,0 +1,384 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Editor für Übungsblätter (Eltern): Einstellungen, Voreinstellungen je Modus, Sperre nach dem ersten Versuch, Aufgaben, Zuweisen.</summary>
public sealed class SetEditorTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open(Guid? setId = null, bool singleChild = true)
{
if (singleChild)
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben); // genau ein Kind → wird automatisch gewählt
}
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<SetEditor>(c => c.Add(x => x.SetId, setId)));
}
private static void FillTitle(IRenderedComponent<LearningHost> cut, string title) => cut.Find("#set-title").Change(title);
private static void Save(IRenderedComponent<LearningHost> cut) => cut.Find("form#learning-set-form").Submit();
private static string Flat(AngleSharp.Dom.IElement element) => System.Text.RegularExpressions.Regex.Replace(element.TextContent, @"\s+", " ").Trim();
private string CurrentUri => Services.GetRequiredService<NavigationManager>().Uri;
// ---------- Neues Blatt ----------
[Fact]
public void New_sheet_starts_with_practice_presets_and_creates_a_draft()
{
var cut = Open();
Assert.Equal("Neues Übungsblatt", cut.Find("h1").TextContent);
Assert.Empty(cut.FindAll(".fp-set-items")); // Aufgaben gibt es erst nach dem ersten Speichern
Assert.Contains("Übung", cut.Find(".fp-set-mode-practice").TextContent);
Assert.Equal("true", cut.Find(".fp-set-mode-practice").GetAttribute("aria-checked"));
Assert.Equal("0", cut.Find(".fp-set-attempts input").GetAttribute("value"));
Assert.Contains("beliebig oft", cut.Find(".fp-set-attempts").TextContent);
Assert.True(cut.Find(".fp-set-solutions input").HasAttribute("checked"));
Assert.Empty(cut.FindAll(".fp-set-thresholds")); // Notenschlüssel nur für Prüfungen
FillTitle(cut, " Plus und Minus ");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal(("Plus und Minus", LearningSubjects.Math, 3, FakeLearningApi.Mia), (request.Title.Trim(), request.Subject, request.GradeLevel, request.TargetUserId));
Assert.Equal((LearningMode.Practice, 0, (int?)null, true, false, 10, (string?)null), (request.Mode, request.MaxAttempts, request.TimeLimitMinutes, request.ShowSolutionsAfterSubmit, request.ShuffleItems, request.TotalPoints, request.GradeThresholds));
var created = Api.Sets.Single();
Assert.EndsWith($"lernen/blatt/{created.Id}", CurrentUri);
}
[Fact]
public void Validation_errors_are_shown_next_to_the_fields_and_nothing_is_sent()
{
var cut = Open(singleChild: false); // zwei Kinder: keines vorausgewählt
Save(cut);
Assert.Empty(Api.Created);
Assert.Contains("Bitte einen Titel eingeben.", cut.Markup);
Assert.Contains("Bitte ein Kind auswählen.", cut.Markup);
}
[Fact]
public void Switching_to_exam_applies_exam_presets_until_the_attempts_are_changed_by_hand()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
Assert.Equal("true", cut.Find(".fp-set-mode-exam").GetAttribute("aria-checked"));
Assert.Equal("1", cut.Find(".fp-set-attempts input").GetAttribute("value"));
Assert.Contains("1 Versuch", cut.Find(".fp-set-attempts").TextContent);
Assert.NotEmpty(cut.FindAll(".fp-set-thresholds"));
Assert.False(cut.Find(".fp-set-solutions input").HasAttribute("checked")); // Prüfung: Lösungen standardmäßig nicht zeigen
FillTitle(cut, "Klassenarbeit");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal((LearningMode.Exam, 1, false), (request.Mode, request.MaxAttempts, request.ShowSolutionsAfterSubmit));
}
[Fact]
public void Manually_chosen_attempts_survive_a_mode_switch()
{
var cut = Open();
cut.Find(".fp-set-attempts input").Change("3");
cut.Find(".fp-set-mode-exam").Click();
Assert.Equal("3", cut.Find(".fp-set-attempts input").GetAttribute("value"));
}
[Fact]
public void Time_limit_and_grade_scale_are_part_of_the_request()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
cut.Find(".fp-set-timelimit-switch input").Change(true);
cut.Find(".fp-set-timelimit input").Change("25");
cut.Find(".fp-set-thresholds input").Change("90;80;70;60;50");
cut.Find(".fp-set-total input").Change("20");
FillTitle(cut, "Mit Zeit");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal((25, "90;80;70;60;50", 20), (request.TimeLimitMinutes, request.GradeThresholds, request.TotalPoints));
}
[Fact]
public void An_invalid_grade_scale_is_rejected_before_sending()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
cut.Find(".fp-set-thresholds input").Change("30;50;67;81;92");
FillTitle(cut, "Falscher Schlüssel");
Save(cut);
Assert.Empty(Api.Created);
Assert.Contains("müssen von Note 1 bis 5 abnehmen", cut.Markup);
}
// ---------- Bearbeiten ----------
[Fact]
public void Existing_sheets_are_loaded_edited_with_the_version_and_conflicts_are_reported()
{
var detail = Api.AddSet("Bruchrechnen", LearningSetStatus.Draft, LearningMode.Practice, itemCount: 2, timeLimit: null, totalPoints: 12);
var cut = Open(detail.Set.Id);
Assert.Equal("Übungsblatt", cut.Find("h1").TextContent);
Assert.Equal("Bruchrechnen", cut.Find("#set-title").GetAttribute("value"));
Assert.Equal("Entwurf", Text(cut.Find(".fp-learning-status")));
Assert.Equal("12", cut.Find(".fp-set-total input").GetAttribute("value"));
FillTitle(cut, "Bruchrechnen 2");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Updated));
Assert.Equal(("Bruchrechnen 2", detail.Set.Version), (Api.Updated[0].Request.Title, Api.Updated[0].Request.Version));
// Jemand anderes ändert zwischenzeitlich: unsere Version ist veraltet → 409 mit Hinweis, danach neue Version
var current = Api.Details[detail.Set.Id];
Api.Details[detail.Set.Id] = current with { Set = current.Set with { Version = [9, 9] } };
FillTitle(cut, "Bruchrechnen 3");
Save(cut);
cut.WaitForAssertion(() => Assert.Contains("Das Blatt wurde inzwischen von jemand anderem geändert.", cut.Find(".fp-learning-conflict").TextContent));
Assert.Equal("Bruchrechnen 3", cut.Find("#set-title").GetAttribute("value")); // Eingabe bleibt stehen
Save(cut);
cut.WaitForAssertion(() => Assert.Equal(3, Api.Updated.Count));
Assert.Equal(new byte[] { 9, 9 }, Api.Updated[2].Request.Version);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-learning-conflict")));
}
[Fact]
public void Sheets_with_attempts_are_locked_for_items_and_fixed_settings()
{
var detail = Api.AddSet("Schon gelöst", LearningSetStatus.Submitted, itemCount: 2, attempts: 1);
var cut = Open(detail.Set.Id);
Assert.Contains("Es gibt schon Versuche", cut.Find(".fp-learning-locked").TextContent);
Assert.Empty(cut.FindAll(".fp-set-add-item"));
Assert.Empty(cut.FindAll(".fp-set-item-actions"));
Assert.NotEmpty(cut.FindAll(".fp-set-item")); // Aufgaben bleiben lesbar
Assert.True(cut.Find(".fp-set-mode-practice").ClassList.Contains("mud-disabled") || cut.Find(".fp-set-mode-practice").HasAttribute("disabled"));
Assert.True(cut.Find(".fp-set-total input").HasAttribute("disabled"));
Assert.True(cut.Find(".fp-set-shuffle input").HasAttribute("disabled"));
Assert.False(cut.Find("#set-title").HasAttribute("disabled")); // Titel & Co. bleiben änderbar
}
[Fact]
public void Missing_sheets_are_reported()
{
var cut = Open(Guid.NewGuid());
Assert.Contains("Dieses Blatt gibt es nicht (mehr).", cut.Find(".fp-learning-missing").TextContent);
}
// ---------- Aufgaben ----------
[Fact]
public void Items_are_listed_with_type_points_and_solution_and_can_be_reordered_and_deleted()
{
var detail = Api.AddSet("Mit Aufgaben", itemCount: 3);
var cut = Open(detail.Set.Id);
var ids = detail.Items.Select(i => i.Id).ToList();
var rows = cut.FindAll(".fp-set-item");
Assert.Equal(3, rows.Count);
Assert.Contains("Frage 1 von Mit Aufgaben", Flat(rows[0]));
Assert.Contains("Zahl · Leicht · 1 Punkt · Lösung: 12", Flat(rows[0]));
Assert.True(rows[0].QuerySelector(".fp-item-up")!.HasAttribute("disabled"));
Assert.True(rows[2].QuerySelector(".fp-item-down")!.HasAttribute("disabled"));
rows[0].QuerySelector(".fp-item-down")!.Click();
cut.WaitForAssertion(() => Assert.Single(Api.Reordered));
Assert.Equal([ids[1], ids[0], ids[2]], Api.Reordered[0].Order);
cut.WaitForAssertion(() => Assert.Contains("Frage 2", cut.FindAll(".fp-set-item")[0].TextContent));
var click = cut.InvokeAsync(() => cut.FindAll(".fp-set-item")[2].QuerySelector(".fp-item-delete")!.Click());
cut.WaitForAssertion(() => Assert.Contains("Aufgabe löschen?", cut.Markup));
Assert.Contains("Aufgabe 3 wirklich löschen?", cut.Markup);
cut.FindAll("button").First(b => Text(b) == "Löschen").Click();
cut.WaitForAssertion(() => Assert.Equal([(detail.Set.Id, ids[2])], Api.DeletedItems));
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-set-item").Count));
Assert.Contains("Aufgaben (2)", cut.Find(".fp-set-items h2").TextContent);
Assert.True(click.IsCompleted || !click.IsFaulted);
}
[Fact]
public void A_number_item_can_be_added_through_the_dialog_with_default_points_by_difficulty()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
Assert.Contains("Noch keine Aufgaben", cut.Find(".fp-set-no-items").TextContent);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.Contains("Aufgabe hinzufügen", cut.Find(".mud-dialog-title").TextContent));
cut.Find("#item-question").Change("Was ist 7 + 5?");
cut.Find("#item-correct").Change("12");
cut.Find("#item-tolerance").Change("0,5");
cut.Find("#item-hint").Change("Zähle weiter.");
cut.Find("#item-explanation").Change("7 plus 5 ist 12.");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AddedItems));
var (setId, item) = Api.AddedItems[0];
Assert.Equal(detail.Set.Id, setId);
Assert.Equal(("Was ist 7 + 5?", LearningAnswerType.Number, "12", 0.5m, "Zähle weiter.", "7 plus 5 ist 12.", 1, 1),
(item.Question, item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Hint, item.Explanation, item.Difficulty, item.Points));
Assert.Null(item.Options);
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-set-item")));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
Assert.Contains("Aufgaben (1)", cut.Find(".fp-set-items h2").TextContent);
}
[Fact]
public void Item_dialog_validates_inputs_and_shows_messages_without_calling_the_api()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte eine Frage eingeben.", cut.Markup));
Assert.Contains("Die Musterlösung muss eine Zahl sein.", cut.Markup);
Assert.Empty(Api.AddedItems);
cut.Find("#item-question").Change("Frage");
cut.Find("#item-correct").Change("zwölf");
cut.Find("#item-tolerance").Change("viel");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte eine Zahl eingeben.", cut.Markup));
Assert.Empty(Api.AddedItems);
}
[Fact]
public void Choice_items_need_options_and_a_marked_correct_answer()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
cut.Find("#item-question").Change("Hauptstadt von Frankreich?");
// Antworttyp „Auswahl“ wählen (MudSelect öffnet ein Popover mit den Einträgen)
cut.Find(".fp-item-type").MouseDown();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
cut.FindAll(".mud-list-item").First(i => Text(i) == "Auswahl").Click();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-item-option").Count));
Assert.True(cut.FindAll(".fp-item-option-remove").All(b => b.HasAttribute("disabled"))); // mindestens zwei Optionen
cut.FindAll(".fp-item-option input")[0].Change("Berlin");
cut.FindAll(".fp-item-option input")[1].Change("Paris");
cut.Find(".fp-item-option-add").Click();
cut.FindAll(".fp-item-option input")[2].Change("Rom");
Assert.Equal(3, cut.FindAll(".fp-item-option").Count);
cut.FindAll(".fp-item-correct")[1].Click(); // Paris ist richtig
Assert.Equal("true", cut.FindAll(".fp-item-correct")[1].GetAttribute("aria-checked"));
Assert.Equal("false", cut.FindAll(".fp-item-correct")[0].GetAttribute("aria-checked"));
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AddedItems));
var item = Api.AddedItems[0].Item;
Assert.Equal((LearningAnswerType.SingleChoice, "1"), (item.AnswerType, item.CorrectAnswer));
Assert.Equal(["Berlin", "Paris", "Rom"], item.Options);
}
[Fact]
public void Existing_items_are_prefilled_and_updated_with_their_version()
{
var detail = Api.AddSet("Ändern", itemCount: 1);
var cut = Open(detail.Set.Id);
cut.Find(".fp-item-edit").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
Assert.Equal("Frage 1 von Ändern", cut.Find("#item-question").GetAttribute("value") ?? cut.Find("#item-question").TextContent.Trim());
Assert.Equal("12", cut.Find("#item-correct").GetAttribute("value"));
cut.Find("#item-question").Change("Neue Frage");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.UpdatedItems));
var (setId, itemId, request) = Api.UpdatedItems[0];
Assert.Equal((detail.Set.Id, detail.Items[0].Id, "Neue Frage", detail.Items[0].Version), (setId, itemId, request.Question, request.Version));
cut.WaitForAssertion(() => Assert.Contains("Neue Frage", cut.Find(".fp-set-item-question").TextContent));
}
// ---------- Zuweisen ----------
[Fact]
public void Drafts_can_be_assigned_and_unassigned_while_no_attempt_exists()
{
var detail = Api.AddSet("Zuweisen", itemCount: 2);
var cut = Open(detail.Set.Id);
Assert.Contains("Das Blatt ist noch ein Entwurf", cut.Find(".fp-set-assign").TextContent);
Assert.Equal("An Mia zuweisen", Text(cut.Find(".fp-set-assign-button")));
cut.Find(".fp-set-assign-button").Click();
cut.WaitForAssertion(() => Assert.Equal([detail.Set.Id], Api.Assigned));
cut.WaitForAssertion(() => Assert.Equal("Zugewiesen", Text(cut.Find(".fp-learning-status"))));
Assert.Contains("Noch kein Versuch.", cut.Find(".fp-set-assign").TextContent);
cut.Find(".fp-set-unassign-button").Click();
cut.WaitForAssertion(() => Assert.Equal([detail.Set.Id], Api.Unassigned));
cut.WaitForAssertion(() => Assert.Equal("Entwurf", Text(cut.Find(".fp-learning-status"))));
}
[Fact]
public void Assigning_a_sheet_the_server_rejects_shows_the_reason()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
Api.Offline = true;
cut.Find(".fp-set-assign-button").Click();
cut.WaitForAssertion(() => Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-dialog-error").TextContent));
}
[Fact]
public void Attempts_are_listed_with_links_to_the_review()
{
var detail = Api.AddSet("Mit Versuchen", LearningSetStatus.Submitted, attempts: 2);
var submitted = new LearningAttemptSummaryDto(Guid.NewGuid(), 1, LearningAttemptStatus.Reviewed, FakeLearningApi.Now, FakeLearningApi.Now, false, 3, 4, 3);
var waiting = new LearningAttemptSummaryDto(Guid.NewGuid(), 2, LearningAttemptStatus.Submitted, FakeLearningApi.Now, FakeLearningApi.Now, true, null, null, null);
var running = new LearningAttemptSummaryDto(Guid.NewGuid(), 3, LearningAttemptStatus.InProgress, FakeLearningApi.Now, null, false, null, null, null);
Api.Details[detail.Set.Id] = detail with { Attempts = [submitted, waiting, running] };
var cut = Open(detail.Set.Id);
var entries = cut.FindAll(".fp-set-attempts-list li");
Assert.Equal(3, entries.Count);
Assert.Contains("geprüft: 3 von 4 Punkten, Note 3", entries[0].TextContent);
Assert.Equal("Ansehen", Text(entries[0].QuerySelector(".fp-set-review-link")!));
Assert.Contains("abgegeben (Zeit abgelaufen), wartet auf Prüfung", entries[1].TextContent);
Assert.Equal("Prüfen", Text(entries[1].QuerySelector(".fp-set-review-link")!));
Assert.Equal($"lernen/pruefen/{waiting.Id}", entries[1].QuerySelector(".fp-set-review-link")!.GetAttribute("href"));
Assert.Contains("läuft", entries[2].TextContent);
Assert.Null(entries[2].QuerySelector(".fp-set-review-link"));
}
}
@@ -179,6 +179,8 @@ internal sealed class FakeLiveConnection : ILiveConnection
public event Func<Task>? TaskPointsChanged;
public event Func<FamilyPlanner.Shared.Learning.LearningChangedDto, Task>? LearningChanged;
public event Func<Guid, Task>? CalendarChanged;
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
@@ -210,6 +212,8 @@ internal sealed class FakeLiveConnection : ILiveConnection
public Task RaiseChatTyping(Guid channelId, Guid userId) => ChatTyping?.Invoke(channelId, userId) ?? Task.CompletedTask;
public Task RaiseLearningChanged(FamilyPlanner.Shared.Learning.LearningChangedDto change) => LearningChanged?.Invoke(change) ?? Task.CompletedTask;
public Task RaiseCalendarChanged(Guid calendarId) => CalendarChanged?.Invoke(calendarId) ?? Task.CompletedTask;
public Task EnsureStartedAsync() => Task.CompletedTask;