merge: Phase 10a learning tasks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e1c1492fc5
70 files changed
+13390
-13
No files matched your search
@@ -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
@@ -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,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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2291
File diff suppressed because it is too large.
Load diff
+241
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user