merge: aa29e771b136e00b0

This commit is contained in:
Jannis committed 2026-10-10 22:31:07 +02:00
commit 19ea6d7f0a
22 files changed
+680 -14

No files matched your search

+1 -1
View File
@@ -87,7 +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; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
| 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 (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), 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, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
| 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 (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice/Paper (Paper je Aufgabe oder für das ganze Blatt in einem Schritt, 12a), 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, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
| Betrieb | `AuditLogEntry` ✅ (ActorName, Action, TargetType/Id/Name, Details, CreatedAt), `PushSubscription` ✅ (UserId, Endpoint + Hash, P256dh, Auth, LastSuccessAt, FailureCount) |
+4 -2
View File
@@ -162,6 +162,10 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- [x] KI-Chat-Kanal: `ChatChannelKind.Ai` (privat je Person, Absender „KI“ ohne Benutzerkonto), `AiPurpose.ChatAssistant` ohne Rückfall auf den Standard-Anbieter, Admin-Schalter und Kinder-Freigabe in den Familieneinstellungen (Standard aus/nur Eltern), fester kindgerechter System-Prompt, kurzer Kontext, Tageslimit, Protokoll ohne Inhalt, Fehlermeldungen ohne Anbietertext; Migration `Phase11bChat`
- [x] Tests: Unit (Regeln, Prompt, GIF-Prüfung, Giphy-Adapter mit Stub), bUnit (Emoji, GIF, KI-Kanal), Integration (Rechte je Rolle, Privatsphäre, kein Rückfall, Limit, Fehler, Idempotenz)
## Phase 12a – Papierblatt und Zutatenliste
- [x] Lernen: „Papier-Abgabe“ (Foto) für das gesamte Blatt in einem Schritt einstellen, nicht nur je Aufgabe – „Alle Aufgaben auf Papier stellen“ / „Alle auf Bildschirm“ im Blatt-Editor (`POST /api/learning/sets/{id}/items/paper`, `LearningManage`, Versionsprüfung, nach dem ersten Versuch gesperrt), Schalter „Auf Papier lösen“ im KI-Entwurf (`CreateAiDraftRequest.Paper`); Regeln in `LearningItemRules.TryConvertToPaper`/`ConvertToScreen`, keine Migration (ADR-0021)
- [x] Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“ – Zeilen mit Menge und Name im Essenplan-Dialog, bereits offene Artikel der Standardliste werden markiert, Snackbar-Rückmeldung; Speicherformat unverändert (`MealPlanEntry.Ingredients` bleibt Liste von Texten), keine Migration (ADR-0013)
## Phase 12 – Deployment
- [ ] Finale `docker-compose.yml` (inkl. optionalem Caddy), `.env.example`
- [ ] Backup-/Restore-Skripte
@@ -181,8 +185,6 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
- Rezeptdatenbank
- Geburtstage und Jahrestage
- Wand-Tablet-Modus (Kiosk-Dashboard)
- Lernen: „Papier-Abgabe“ (Foto) für das gesamte Blatt in einem Schritt einstellen, nicht nur je Aufgabe
- KI: optionale Online-Suche in Prompts (Ja/Nein, Opt-in je Anbieter und je Anfrage; nur für Anbieter, die das unterstützen, z. B. Claude-Web-Suche, Perplexity; Datenschutzhinweis)
- KI: vollständiges Protokoll aller abgeschickten Prompts samt Antwort in der Datenbank (Admin-Ansicht, Aufbewahrungsdauer, Löschen; Inhalte des privaten KI-Chats nur auf ausdrückliche Admin-Einstellung)
- Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“
- Kalender: Freigabe und Zwei-Wege-Synchronisation mit iOS/macOS (CalDAV-Server, ADR-0017) – Outlook über Microsoft Graph als eigener Folgeschritt
+7
View File
@@ -45,6 +45,13 @@ Schnellerfassung (gelernte Kategorie, sonst Stichwörter). Bereits **offene** Ar
Zutaten werden übersprungen und im Ergebnis gemeldet. Ohne Listenangabe wird die Standardliste verwendet; im Dialog lassen sich
Liste und einzelne Zutaten auswählen. Die neuen Artikel werden wie üblich per `ShoppingItemChanged` an alle Clients gesendet.
**Zutatenliste (Phase 12a):** Im Eintragsdialog sind die Zutaten Zeilen aus Menge (optional) und Name statt eines Textfelds. Gespeichert bleibt
unverändert `MealPlanEntry.Ingredients` (eine Zutat = ein Text, z. B. „500 g Hackfleisch“; keine Migration): Beim Laden wird die Menge nur abgespalten,
wenn sie wörtlich am Anfang steht, sonst steht alles im Namen – alter Freitext geht so nie verloren. Jede Zeile hat „In den Einkaufskorb“ (nur mit
`ShoppingWrite`, nur für gespeicherte Einträge): derselbe Endpunkt `to-shopping` mit genau dieser Zutat, Standardliste, Dubletten erkennt der Server;
Rückmeldung per Snackbar. Beim Öffnen werden schon offene Artikel der Standardliste markiert. Offen: Eine dauerhafte „bereits übernommen“-Spur je
Zutat gibt es nicht, die Markierung beruht auf den offenen Artikeln der Liste.
**Live-Updates:** Gruppe `meals` (alle Rollen) für `MealEntryChanged` (DTO) und `MealEntryRemoved` (Id); Wunschänderungen gehen
als `MealWishesChanged` **ohne Nutzdaten** an die Gruppe `family` – jeder Client lädt seine (ggf. gefilterte) Sicht neu, damit
Kinder keine Wünsche anderer Kinder erhalten.
+4
View File
@@ -166,6 +166,10 @@ Kinder-DTOs haben weiterhin keine Felder für Prompt/Anbieter/Modell/Quelle; das
* Die Anfrage hängt am HttpClient-Zeitlimit des Browsers (Standard 100 s): Antwortet ein langsames lokales Modell später, meldet der Dialog das; ein trotzdem fertig gewordener Entwurf erscheint in der Liste. Anbieter-Zeitlimits über 100 s helfen für diesen Endpunkt also nicht.
* Es gibt keine Streaming-Anzeige und keinen Abbruch-Knopf während der Erzeugung.
### Phase 12a: ganzes Blatt auf Papier
* `POST /api/learning/sets/{id}/items/paper` (`SetSheetPaperRequest(Paper, Version)`, `LearningManage`) stellt alle Aufgaben auf `Paper` oder zurück auf den Bildschirm; wie die übrigen Aufgaben-Änderungen nur ohne Versuch (409), mit Versionsprüfung des Blatts (409 liefert den aktuellen Stand) und idempotent. Im KI-Entwurf (`CreateAiDraftRequest.Paper`) werden die geprüften Aufgaben nach dem Parsen umgestellt.
* Regeln (`LearningItemRules`): `Paper` verlangt eine Musterlösung (höchstens 200 Zeichen). Bei Auswahlaufgaben wird der Text der richtigen Option zur Musterlösung (die Optionen bleiben gespeichert); Aufgaben ohne brauchbare Musterlösung bleiben unverändert am Bildschirm, der Editor meldet ihre Zahl. „Auf Bildschirm“ stellt eine Auswahl wieder her, wenn die Optionen noch passen, macht sonst eine reine Zahl zu `Number` und alles andere zu `Text` (der ursprüngliche Typ wird nicht gespeichert, daher keine Migration).
### 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`
@@ -57,6 +57,11 @@ internal static class LearningAiDraftPipeline
{
var item = LearningValidation.ToItem(input);
item.SortOrder = order++;
if (request.Paper)
{
LearningItemRules.TryConvertToPaper(item, out _); // nicht umstellbare Aufgaben bleiben am Bildschirm
}
set.Items.Add(item);
}
@@ -35,6 +35,7 @@ internal static class LearningManageEndpoints
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/paper", SetSheetPaperAsync).Validate<SetSheetPaperRequest>();
manage.MapPost("/sets/{id:guid}/items/order", ReorderItemsAsync).Validate<ReorderLearningItemsRequest>();
manage.MapGet("/review", ReviewQueueAsync);
@@ -311,6 +312,42 @@ internal static class LearningManageEndpoints
return await SaveItemsAsync(db, set, notifier, ct);
}
/// <summary>
/// Alle Aufgaben auf Papier oder zurück auf den Bildschirm (Phase 12a). Nicht umstellbare Aufgaben (z. B. ohne Musterlösung) bleiben unverändert;
/// die Antwort enthält den neuen Stand, sodass der Client sie daran erkennt. Idempotent.
/// </summary>
private static async Task<IResult> SetSheetPaperAsync(Guid id, SetSheetPaperRequest request, AppDbContext db, LearningNotifier notifier, CancellationToken ct)
{
var (set, error) = await LoadEditableAsync(db, id, ct);
if (set is null)
{
return error!;
}
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(set.RowVersion))
{
return TypedResults.Conflict(await ConflictDetailAsync(db, id, ct));
}
var changed = false;
foreach (var item in set.Items)
{
var before = (item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Options.Count);
if (request.Paper)
{
LearningItemRules.TryConvertToPaper(item, out _);
}
else
{
LearningItemRules.ConvertToScreen(item);
}
changed |= before != (item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Options.Count);
}
return changed ? await SaveItemsAsync(db, set, notifier, ct) : TypedResults.Ok(await LoadDetailAsync(db, id, 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)
{
@@ -391,6 +391,83 @@ public static class LearningItemRules
public static bool IsSupported(LearningAnswerType type) =>
type is LearningAnswerType.Number or LearningAnswerType.Text or LearningAnswerType.SingleChoice or LearningAnswerType.Paper;
/// <summary>
/// Stellt eine Aufgabe auf Papier (Foto) um (Phase 12a). Die Musterlösung bleibt als Maßstab für Eltern und KI-Prüfung; bei einer Auswahlaufgabe
/// wird der Text der richtigen Option zur Musterlösung (die Optionen bleiben gespeichert, damit „Auf Bildschirm“ sie wiederherstellen kann).
/// Liefert <c>false</c> mit Grund, wenn das nicht möglich ist; die Aufgabe bleibt dann unverändert.
/// </summary>
public static bool TryConvertToPaper(LearningItem item, out string? error)
{
error = null;
switch (item.AnswerType)
{
case LearningAnswerType.Paper:
return true;
case LearningAnswerType.SingleChoice:
if (!int.TryParse(item.CorrectAnswer, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var index)
|| index >= item.Options.Count
|| string.IsNullOrWhiteSpace(item.Options[index]))
{
error = "Die richtige Option fehlt.";
return false;
}
item.CorrectAnswer = item.Options[index].Trim();
break;
default:
break;
}
if (string.IsNullOrWhiteSpace(item.CorrectAnswer))
{
error = "Es gibt keine Musterlösung.";
return false;
}
if (item.CorrectAnswer.Length > LearningItem.CorrectAnswerMaxLength)
{
error = "Die Musterlösung ist zu lang.";
return false;
}
item.AnswerType = LearningAnswerType.Paper;
return true;
}
/// <summary>
/// Stellt eine Papieraufgabe wieder auf den Bildschirm um: Auswahl, wenn die Optionen noch da sind und die Musterlösung einer entspricht;
/// Zahl, wenn die Musterlösung eine reine Zahl ist (oder eine Toleranz gespeichert ist); sonst Text. Andere Typen bleiben unverändert.
/// </summary>
public static void ConvertToScreen(LearningItem item)
{
if (item.AnswerType != LearningAnswerType.Paper)
{
return;
}
var answer = item.CorrectAnswer?.Trim() ?? string.Empty;
var optionIndex = item.Options.Count is >= LearningItem.MinOptions and <= LearningItem.MaxOptions
? item.Options.FindIndex(o => string.Equals(o.Trim(), answer, StringComparison.Ordinal))
: -1;
if (optionIndex >= 0)
{
item.AnswerType = LearningAnswerType.SingleChoice;
item.CorrectAnswer = optionIndex.ToString(System.Globalization.CultureInfo.InvariantCulture);
return;
}
item.Options = [];
var isPlainNumber = answer.Length > 0 && answer.All(c => char.IsDigit(c) || c is ',' or '.' or '-' or ' ') && LearningNumbers.Parse(answer).Count == 1;
if (isPlainNumber || (item.NumberTolerance is not null && LearningNumbers.Parse(answer).Count > 0))
{
item.AnswerType = LearningAnswerType.Number;
return;
}
item.AnswerType = LearningAnswerType.Text;
item.NumberTolerance = null;
}
/// <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);
}
@@ -28,7 +28,7 @@ public static class LearningAiDraft
/// <summary>
/// Parameter für den KI-Entwurf. Der Freitext <see cref="Extra"/> ist ein Wunsch an die KI (Thema, Stil) und nie eine Anweisung an das System.
/// Das Ergebnis ist ein <b>Entwurf</b> für <see cref="TargetUserId"/> (ein Kind); zugewiesen wird erst nach der Durchsicht.
/// Mit <see cref="Paper"/> sind alle Aufgaben von Anfang an Papieraufgaben (Foto, Phase 12a). Das Ergebnis ist ein <b>Entwurf</b> für <see cref="TargetUserId"/> (ein Kind); zugewiesen wird erst nach der Durchsicht.
/// </summary>
public sealed record CreateAiDraftRequest(
string Subject,
@@ -37,7 +37,8 @@ public sealed record CreateAiDraftRequest(
int Count = 10,
LearningDifficultyCurve Difficulty = LearningDifficultyCurve.EasyToHard,
string? Extra = null,
LearningMode Mode = LearningMode.Practice);
LearningMode Mode = LearningMode.Practice,
bool Paper = false);
/// <summary>
/// Der gespeicherte Entwurf samt Bericht der Prüfung: wie viele Aufgaben verworfen wurden, wie viele Rechenaufgaben nicht nachgerechnet werden
@@ -121,6 +121,12 @@ public sealed record UpdateLearningItemRequest(
public sealed record ReorderLearningItemsRequest(IReadOnlyList<Guid> ItemIds);
/// <summary>
/// Alle Aufgaben eines Blatts auf Papier (Foto) oder zurück auf den Bildschirm stellen (Phase 12a). <see cref="Version"/> ist die Version des Blatts;
/// passt sie nicht, antwortet die Api mit 409 und dem aktuellen Stand.
/// </summary>
public sealed record SetSheetPaperRequest(bool Paper, byte[]? Version = null);
/// <summary>Gemeinsame Einstellungen eines Blatts.</summary>
public interface ILearningSetFields
{
@@ -475,6 +481,12 @@ public sealed class ReorderLearningItemsRequestValidator : AbstractValidator<Reo
.WithMessage("Ungültige Reihenfolge.");
}
public sealed class SetSheetPaperRequestValidator : AbstractValidator<SetSheetPaperRequest>
{
public SetSheetPaperRequestValidator() =>
RuleFor(r => r.Version).Must(v => v is null || v.Length <= 16).WithMessage("Ungültige Version.");
}
public sealed class SaveAnswerRequestValidator : AbstractValidator<SaveAnswerRequest>
{
public SaveAnswerRequestValidator() =>
@@ -68,6 +68,8 @@
<MudSelectItem T="LearningMode" Value="@LearningMode.Exam">Prüfung (einmalig, ohne Hilfen)</MudSelectItem>
</MudSelect>
<MudSwitch T="bool" @bind-Value="_paper" Label="Auf Papier lösen (Foto-Abgabe für alle Aufgaben)" Color="Color.Primary" Class="fp-ai-paper" Disabled="_busy" />
<MudTextField @bind-Value="_extra" Label="Zusatzwunsch (optional)" InputId="ai-extra" Variant="Variant.Outlined" Lines="3" Class="fp-ai-extra" Disabled="_busy"
MaxLength="LearningAiDraft.ExtraMaxLength" Counter="LearningAiDraft.ExtraMaxLength" Immediate="true"
Placeholder="z. B. Textaufgaben zum Einkaufen, nur Malnehmen mit 3 und 4"
@@ -162,6 +164,7 @@
private int _count = 10;
private LearningDifficultyCurve _difficulty = LearningDifficultyCurve.EasyToHard;
private LearningMode _mode = LearningMode.Practice;
private bool _paper;
private string? _extra;
private bool _busy;
private string? _error;
@@ -198,7 +201,7 @@
_error = null;
_errors = [];
var request = new CreateAiDraftRequest(_subject, _gradeLevel, _target, _count, _difficulty, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(), _mode);
var request = new CreateAiDraftRequest(_subject, _gradeLevel, _target, _count, _difficulty, string.IsNullOrWhiteSpace(_extra) ? null : _extra.Trim(), _mode, _paper);
// Sofortiges Feedback mit denselben Regeln wie die Api (verbindlich ist die serverseitige Prüfung).
foreach (var failure in new CreateAiDraftRequestValidator().Validate(request).Errors)
@@ -148,6 +148,21 @@
<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 && detail.Items.Count > 0)
{
@if (detail.Items.Any(i => i.AnswerType != LearningAnswerType.Paper))
{
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.PhotoCamera" OnClick="() => SetPaperAsync(true)" Disabled="_busy"
Class="fp-set-all-paper">Alle Aufgaben auf Papier stellen</MudButton>
}
@if (detail.Items.Any(i => i.AnswerType == LearningAnswerType.Paper))
{
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Computer" OnClick="() => SetPaperAsync(false)" Disabled="_busy"
Class="fp-set-all-screen">Alle auf Bildschirm</MudButton>
}
}
@if (!Locked)
{
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Add" OnClick="() => EditItemAsync(null)" Class="fp-set-add-item">Aufgabe hinzufügen</MudButton>
@@ -512,6 +527,32 @@
await RunAsync(() => Api.ReorderItemsAsync(detail.Set.Id, ids), "Die Reihenfolge konnte nicht geändert werden.");
}
/// <summary>Alle Aufgaben in einem Schritt auf Papier (Foto) oder auf den Bildschirm stellen (Phase 12a); die Api prüft jede Aufgabe.</summary>
private async Task SetPaperAsync(bool paper)
{
if (_detail is not { } detail)
{
return;
}
if (!await RunAsync(() => Api.SetSheetPaperAsync(detail.Set.Id, new SetSheetPaperRequest(paper, detail.Set.Version)), "Die Aufgaben konnten nicht umgestellt werden."))
{
return;
}
var skipped = paper
? _detail?.Items.Count(i => i.AnswerType != LearningAnswerType.Paper) ?? 0
: _detail?.Items.Count(i => i.AnswerType == LearningAnswerType.Paper) ?? 0;
if (skipped > 0)
{
Snackbar.Add($"{skipped} Aufgabe(n) ließen sich nicht umstellen (Musterlösung fehlt). Bitte einzeln prüfen.", Severity.Warning);
}
else
{
Snackbar.Add(paper ? "Alle Aufgaben sind jetzt Papieraufgaben: Das Kind lädt ein Foto hoch." : "Alle Aufgaben werden am Bildschirm gelöst.", Severity.Success);
}
}
private async Task AssignAsync()
{
if (_detail is not { } detail)
@@ -1,5 +1,10 @@
@using FamilyPlanner.Domain.Meals
@using FamilyPlanner.Domain.Shopping
@using FamilyPlanner.Shared.Meals
@using FamilyPlanner.Shared.Shopping
@inject IMealsApi MealsApi
@inject IShoppingApi ShoppingApi
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
@@ -32,9 +37,45 @@
<MudTextField @bind-Value="_recipeUrl" Label="Rezeptlink (optional)" InputId="meal-recipe" Variant="Variant.Outlined"
InputType="InputType.Url" Placeholder="https://…" Error="@HasError("RecipeUrl")" ErrorText="@ErrorFor("RecipeUrl")" />
<MudTextField @bind-Value="_ingredients" Label="Zutaten (optional)" InputId="meal-ingredients" Variant="Variant.Outlined" Lines="5"
HelperText="Eine Zutat pro Zeile, z. B. „500 g Hackfleisch“ – lassen sich später auf die Einkaufsliste setzen."
Error="@HasError("Ingredients")" ErrorText="@ErrorFor("Ingredients")" />
<fieldset class="fp-ingredients" aria-label="Zutaten">
<legend class="mud-typography mud-typography-subtitle2">Zutaten (optional)</legend>
@for (var i = 0; i < _ingredients.Count; i++)
{
var row = _ingredients[i];
var index = i + 1;
var onList = row.OnList(_openNames);
<div class="d-flex align-center gap-1 mb-1 fp-ingredient-row" data-onlist="@(onList ? "true" : "false")">
<MudTextField T="string" @bind-Value="row.Amount" Label="Menge" Variant="Variant.Outlined" Margin="Margin.Dense"
Class="fp-ingredient-amount" Style="max-width: 7.5rem" />
<MudTextField T="string" @bind-Value="row.Name" Label="Zutat" Variant="Variant.Outlined" Margin="Margin.Dense"
Class="flex-grow-1 fp-ingredient-name" />
@if (CanAddToShopping)
{
<MudTooltip Text="@(onList ? "Steht schon auf der Einkaufsliste" : "In den Einkaufskorb")">
<MudIconButton Icon="@(onList ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.AddShoppingCart)"
Color="@(onList ? Color.Success : Color.Default)"
Disabled="@(onList || Entry is null || row.Text.Length == 0 || row.Busy)"
OnClick="() => AddToShoppingAsync(row)" Class="fp-ingredient-cart"
aria-label="@($"Zutat {index} in den Einkaufskorb")" />
</MudTooltip>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="() => _ingredients.Remove(row)" Class="fp-ingredient-remove"
aria-label="@($"Zutat {index} entfernen")" />
</div>
}
@if (HasError("Ingredients"))
{
<MudText Typo="Typo.caption" Color="Color.Error">@ErrorFor("Ingredients")</MudText>
}
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Add" OnClick="AddRow"
Disabled="@(_ingredients.Count >= MealPlanEntry.MaxIngredients)" Class="fp-ingredient-add">Zutat hinzufügen</MudButton>
@if (CanAddToShopping && Entry is null)
{
<MudText Typo="Typo.caption" Class="fp-ingredient-hint">Der Einkaufskorb-Knopf geht, sobald das Gericht gespeichert ist.</MudText>
}
</fieldset>
</MudStack>
</EditForm>
</DialogContent>
@@ -59,6 +100,10 @@
[Parameter]
public MealPlanEntryDto? Entry { get; set; }
/// <summary>Darf der Benutzer die Einkaufsliste ergänzen (<c>ShoppingWrite</c>)? Sonst gibt es keinen Einkaufskorb-Knopf je Zutat; die Api prüft es erneut.</summary>
[Parameter]
public bool CanAddToShopping { get; set; }
/// <summary>Bisherige Gerichte für die Autovervollständigung.</summary>
[Parameter]
public IReadOnlyList<DishSuggestionDto> Dishes { get; set; } = [];
@@ -66,7 +111,9 @@
private string _dishName = string.Empty;
private string? _note;
private string? _recipeUrl;
private string? _ingredients;
private readonly List<IngredientRow> _ingredients = [];
private HashSet<string> _openNames = [];
private ShoppingListDto? _list;
private byte[]? _version;
private List<DishSuggestionDto> _matches = [];
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
@@ -80,11 +127,68 @@
_dishName = entry.DishName;
_note = entry.Note;
_recipeUrl = entry.RecipeUrl;
_ingredients = MealFormat.IngredientsText(entry.Ingredients);
_ingredients.AddRange(entry.Ingredients.Select(IngredientRow.From));
_version = entry.Version;
}
}
protected override async Task OnInitializedAsync()
{
if (!CanAddToShopping)
{
return;
}
// Schon offene Artikel der Standardliste vormerken, damit niemand etwas doppelt in den Korb legt (die Api prüft es zusätzlich).
var lists = await ShoppingApi.GetListsAsync();
_list = lists.Value is { } all ? all.FirstOrDefault(l => l.IsDefault) ?? all.FirstOrDefault() : null;
if (_list is not null)
{
var items = await ShoppingApi.GetItemsAsync(_list.Id);
if (items.Value is { } open)
{
_openNames = open.Where(i => !i.IsDone).Select(i => ShoppingSuggestion.Normalize(i.Name)).ToHashSet(StringComparer.Ordinal);
}
}
}
private void AddRow()
{
if (_ingredients.Count < MealPlanEntry.MaxIngredients)
{
_ingredients.Add(new IngredientRow());
}
}
/// <summary>Eine einzelne Zutat auf die Standardliste setzen (vorhandener Weg <c>to-shopping</c>); Dubletten erkennt die Api.</summary>
private async Task AddToShoppingAsync(IngredientRow row)
{
if (Entry is null || row.Text.Length == 0)
{
return;
}
row.Busy = true;
var result = await MealsApi.IngredientsToShoppingAsync(Entry.Id, new IngredientsToShoppingRequest(_list?.Id, [row.Text]));
row.Busy = false;
if (result.Problem is { } problem)
{
Snackbar.Add(problem.Message ?? "Die Zutat konnte nicht auf die Einkaufsliste gesetzt werden.", Severity.Error);
return;
}
_openNames.Add(ShoppingSuggestion.Normalize(QuantityParser.Parse(row.Text).Name));
var listName = result.Value?.ListName ?? _list?.Name ?? "Einkaufsliste";
if (result.Value is { Added.Count: > 0 })
{
Snackbar.Add($"„{row.Text}“ steht jetzt auf „{listName}“.", Severity.Success);
}
else
{
Snackbar.Add($"„{row.Text}“ steht schon auf „{listName}“.", Severity.Info);
}
}
private void UpdateMatches()
{
var term = _dishName.Trim();
@@ -107,9 +211,10 @@
_recipeUrl = dish.RecipeUrl;
}
if (string.IsNullOrWhiteSpace(_ingredients) && dish.Ingredients.Count > 0)
if (_ingredients.All(r => r.Text.Length == 0) && dish.Ingredients.Count > 0)
{
_ingredients = MealFormat.IngredientsText(dish.Ingredients);
_ingredients.Clear();
_ingredients.AddRange(dish.Ingredients.Select(IngredientRow.From));
}
_matches = [];
@@ -119,7 +224,7 @@
{
_generalError = null;
var request = new SaveMealEntryRequest(Date, MealType, _dishName.Trim(), _note, _recipeUrl?.Trim(),
MealFormat.ParseIngredients(_ingredients), _version);
MealPlanRules.NormalizeIngredients(_ingredients.Select(r => r.Text)), _version);
var validation = new SaveMealEntryRequestValidator().Validate(request);
_errors = validation.Errors
.GroupBy(e => e.PropertyName)
@@ -162,4 +267,26 @@
private string? ErrorFor(string field) => _errors.GetValueOrDefault(field);
private void Cancel() => Dialog?.Cancel();
/// <summary>Eine Zeile der Zutatenliste: Menge (optional) und Name; gespeichert wird beides als ein Text (<see cref="Text"/>).</summary>
private sealed class IngredientRow
{
public string? Amount { get; set; }
public string? Name { get; set; }
public bool Busy { get; set; }
public string Text => MealFormat.JoinIngredient(Amount, Name);
public static IngredientRow From(string text)
{
var (amount, name) = MealFormat.SplitIngredient(text);
return new IngredientRow { Amount = amount, Name = name };
}
/// <summary>Steht der Artikelname schon offen auf der Einkaufsliste?</summary>
public bool OnList(HashSet<string> openNames) =>
Text.Length > 0 && QuantityParser.Parse(Text).Name is { Length: > 0 } name && openNames.Contains(ShoppingSuggestion.Normalize(name));
}
}
@@ -30,6 +30,24 @@ public static class MealFormat
public static string IngredientsText(IEnumerable<string> ingredients) => string.Join("\n", ingredients);
/// <summary>
/// Zerlegt eine gespeicherte Zutat („500 g Hackfleisch“) für die Zeilenbearbeitung in Menge und Name. Gespeichert bleibt ein einzelner Text
/// (<see cref="JoinIngredient"/>): Die Menge wird nur abgespalten, wenn sie der Text wörtlich am Anfang hat, sonst steht alles im Namen. So geht
/// bestehender Freitext nie verloren oder wird umgeschrieben.
/// </summary>
public static (string Amount, string Name) SplitIngredient(string? text)
{
var value = (text ?? string.Empty).Trim();
var quantity = FamilyPlanner.Domain.Shopping.QuantityParser.Parse(value).Quantity;
return quantity is { Length: > 0 } && value.StartsWith(quantity + " ", StringComparison.OrdinalIgnoreCase)
? (value[..quantity.Length], value[(quantity.Length + 1)..].Trim())
: (string.Empty, value);
}
/// <summary>Menge und Name zu einer Zutat zusammenfügen („500 g“ + „Hackfleisch“); leer, wenn beides fehlt.</summary>
public static string JoinIngredient(string? amount, string? name) =>
string.Join(' ', new[] { amount?.Trim(), name?.Trim() }.Where(p => !string.IsNullOrEmpty(p)));
/// <summary>„zuletzt geplant am 12.10.2026“ bzw. „noch nie geplant“ (Familienzeitzone).</summary>
public static string LastPlanned(DateTimeOffset? value) => value is { } at
? $"zuletzt geplant am {TimeZoneInfo.ConvertTime(at, FamilyPlanner.Domain.Common.FamilyTime.Berlin).ToString("dd.MM.yyyy", German)}"
@@ -314,6 +314,7 @@ else
{ d => d.MealType, mealType },
{ d => d.Entry, entry },
{ d => d.Dishes, await DishesAsync() },
{ d => d.CanAddToShopping, CanAddToShopping },
};
var dialog = await DialogService.ShowAsync<MealEntryDialog>($"{MealFormat.Day(day)} · {mealType}", parameters, DialogDefaults);
if (await dialog.Result is { Canceled: false, Data: MealPlanEntryDto saved })
@@ -43,6 +43,9 @@ public interface ILearningApi
Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default);
/// <summary>Alle Aufgaben auf Papier (Foto) oder auf den Bildschirm stellen (Phase 12a). Bei 409 enthält <see cref="ApiResult{T}.Value"/> den aktuellen Stand.</summary>
Task<ApiResult<LearningSetDetailDto>> SetSheetPaperAsync(Guid setId, SetSheetPaperRequest request, CancellationToken ct = default);
Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default);
Task<ApiResult<LearningAttemptReviewDto>> GetReviewAttemptAsync(Guid attemptId, CancellationToken ct = default);
@@ -146,6 +149,9 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
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<LearningSetDetailDto>> SetSheetPaperAsync(Guid setId, SetSheetPaperRequest request, CancellationToken ct = default) =>
Send<LearningSetDetailDto>(() => http.PostAsJsonAsync($"{Base}/sets/{setId}/items/paper", request, ct), ct, currentOnConflict: true);
public Task<ApiResult<List<LearningReviewQueueItemDto>>> GetReviewQueueAsync(CancellationToken ct = default) =>
Send<List<LearningReviewQueueItemDto>>(() => http.GetAsync($"{Base}/review", ct), ct);
@@ -105,6 +105,27 @@ public sealed class LearningAiDraftTests(AppFixture fixture)
Assert.Single(server.Requests);
}
[Fact]
public async Task Paper_flag_makes_every_generated_item_a_paper_item()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
await using var server = FakeAiServer.Start();
server.Respond = _ => (200, OllamaEnvelope(GoodSheet), TimeSpan.Zero);
await ConfigureAsync(admin, server.BaseUrl);
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
using var _k = kid;
using var _p = parent;
var response = await PostDraftAsync(parent, Draft(child.Id) with { Paper = true });
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var dto = (await response.Content.ReadFromJsonAsync<LearningAiDraftDto>(Ct))!;
Assert.Equal(3, dto.Draft.Items.Count);
Assert.All(dto.Draft.Items, i => Assert.Equal(LearningAnswerType.Paper, i.AnswerType));
Assert.Equal("12", dto.Draft.Items.Single(i => i.Question.StartsWith("Welche Zahl")).CorrectAnswer); // Optionstext statt Index
}
[Fact]
public async Task Valid_answer_becomes_an_unassigned_ai_draft_with_provenance_and_a_fixed_system_prompt()
{
@@ -381,6 +381,55 @@ public sealed class LearningTests(AppFixture fixture)
Assert.Equal([3, 2, 1], mine.Results.Select(r => r.Number));
}
[Fact]
public async Task Whole_sheet_can_be_moved_to_paper_and_back_with_permissions_versions_and_lock()
{
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
var set = await CreateAsync(parent, NewSet(child.Id) with
{
Items =
[
new("Zahl?", LearningAnswerType.Number, "12,5", 0.5m),
new("Wahl?", LearningAnswerType.SingleChoice, "1", Options: ["7", "12", "9"]),
new("Text?", LearningAnswerType.Text, "Hund"),
],
}, assign: false);
var url = $"/api/learning/sets/{set.Set.Id}/items/paper";
// Rechte: nur Eltern (LearningManage)
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsJsonAsync(url, new SetSheetPaperRequest(true), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await guest.PostAsJsonAsync(url, new SetSheetPaperRequest(true), Ct)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await parent.PostAsJsonAsync($"/api/learning/sets/{Guid.NewGuid()}/items/paper", new SetSheetPaperRequest(true), Ct)).StatusCode);
// Veraltete Version: 409 mit dem aktuellen Stand, nichts geaendert
var stale = await parent.PostAsJsonAsync(url, new SetSheetPaperRequest(true, [9, 9, 9, 9, 9, 9, 9, 9]), Ct);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.All((await GetSetAsync(parent, set.Set.Id)).Items, i => Assert.NotEqual(LearningAnswerType.Paper, i.AnswerType));
// Alle auf Papier: Musterloesungen bleiben als Massstab, bei der Auswahl wird der Optionstext zur Loesung
var response = await parent.PostAsJsonAsync(url, new SetSheetPaperRequest(true, set.Set.Version), Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var paper = (await response.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
Assert.All(paper.Items, i => Assert.Equal(LearningAnswerType.Paper, i.AnswerType));
Assert.Equal(["12,5", "12", "Hund"], paper.Items.OrderBy(i => i.SortOrder).Select(i => i.CorrectAnswer));
Assert.NotEqual(set.Set.Version, paper.Set.Version);
// Idempotent, dann zurueck auf den Bildschirm: Auswahl wird wiederhergestellt
Assert.Equal(HttpStatusCode.OK, (await parent.PostAsJsonAsync(url, new SetSheetPaperRequest(true), Ct)).StatusCode);
var back = (await (await parent.PostAsJsonAsync(url, new SetSheetPaperRequest(false), Ct)).Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
Assert.Equal(
[LearningAnswerType.Number, LearningAnswerType.SingleChoice, LearningAnswerType.Text],
back.Items.OrderBy(i => i.SortOrder).Select(i => i.AnswerType));
Assert.Equal("1", back.Items.Single(i => i.AnswerType == LearningAnswerType.SingleChoice).CorrectAnswer);
// Nach dem ersten Versuch gesperrt
await parent.PostAsync($"/api/learning/sets/{set.Set.Id}/assign", null, Ct);
await StartAsync(kid, set.Set.Id);
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsJsonAsync(url, new SetSheetPaperRequest(true), Ct)).StatusCode);
}
[Fact]
public async Task Shuffled_order_is_stable_per_attempt()
{
@@ -283,6 +283,34 @@ public sealed class MealTests(AppFixture fixture)
(await admin.PostAsJsonAsync($"/api/meals/{Guid.NewGuid()}/to-shopping", new IngredientsToShoppingRequest(list.Id), Ct)).StatusCode);
}
[Fact]
public async Task Single_ingredient_goes_to_the_default_list_once_and_old_free_text_survives_unchanged()
{
using var admin = await fixture.CreateAdminClientAsync(Ct);
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
using var _ = guest;
var unique = $"Soße{Guid.NewGuid():N}"[..14];
// Altbestand: freie Texte (mit Komma-Menge und ohne Menge) bleiben beim Speichern wortgleich erhalten
var entry = await CreateAsync(admin, NewWeek(), Dinner, "Eintopf", ["1,5 l Brühe", "Salz nach Geschmack", $"200 g {unique}"]);
Assert.Equal(["1,5 l Brühe", "Salz nach Geschmack", $"200 g {unique}"], entry.Ingredients);
// Gast darf nichts auf die Liste setzen; Admin: eine einzelne Zutat, beim zweiten Mal erkennt der Server die Dublette
Assert.Equal(HttpStatusCode.Forbidden,
(await guest.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct)).StatusCode);
var first = (await (await admin.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct))
.Content.ReadFromJsonAsync<IngredientsToShoppingResult>(Ct))!;
Assert.Equal([$"200 g {unique}"], first.Added);
var second = (await (await admin.PostAsJsonAsync($"/api/meals/{entry.Id}/to-shopping", new IngredientsToShoppingRequest(null, [$"200 g {unique}"]), Ct))
.Content.ReadFromJsonAsync<IngredientsToShoppingResult>(Ct))!;
Assert.Empty(second.Added);
Assert.Equal([$"200 g {unique}"], second.Skipped);
var items = (await admin.GetFromJsonAsync<List<ShoppingItemDto>>($"/api/shopping/lists/{first.ListId}/items", Ct))!;
Assert.Single(items, i => i.Name.Equals(unique, StringComparison.OrdinalIgnoreCase) && !i.IsDone);
Assert.DoesNotContain(items, i => i.Name.Contains("Salz")); // nur die gewählte Zutat, nicht der ganze Eintrag
}
private static async Task<List<WishlistDishDto>> WishlistAsync(HttpClient client, string? query = null, string? sort = null) =>
(await client.GetFromJsonAsync<List<WishlistDishDto>>(
$"/api/meals/wishlist?q={Uri.EscapeDataString(query ?? string.Empty)}&sort={sort}", Ct))!;
@@ -349,4 +349,70 @@ public sealed class LearningRulesTests
Assert.Equal((8, 5, -3), (booked, afterReopen, LearningRules.CreditDelta(afterReopen, booked)));
}
// ---------- Papierblatt (Phase 12a) ----------
private static LearningItem PaperItem(LearningAnswerType type, string? correct, List<string>? options = null, decimal? tolerance = null) =>
new() { Question = "Frage", AnswerType = type, CorrectAnswer = correct, Options = options ?? [], NumberTolerance = tolerance };
[Fact]
public void Number_and_text_items_keep_their_solution_when_moved_to_paper()
{
var number = PaperItem(LearningAnswerType.Number, "12,5", tolerance: 0.5m);
var text = PaperItem(LearningAnswerType.Text, "Hund|Hunde");
Assert.True(LearningItemRules.TryConvertToPaper(number, out _));
Assert.True(LearningItemRules.TryConvertToPaper(text, out _));
Assert.Equal((LearningAnswerType.Paper, "12,5"), (number.AnswerType, number.CorrectAnswer));
Assert.Equal((LearningAnswerType.Paper, "Hund|Hunde"), (text.AnswerType, text.CorrectAnswer));
Assert.Null(LearningItemRules.CheckSolution(number));
}
[Fact]
public void Choice_items_get_the_text_of_the_right_option_as_solution_and_can_be_restored()
{
var item = PaperItem(LearningAnswerType.SingleChoice, "1", ["7", "12", "9"]);
Assert.True(LearningItemRules.TryConvertToPaper(item, out _));
Assert.Equal((LearningAnswerType.Paper, "12"), (item.AnswerType, item.CorrectAnswer));
Assert.Null(LearningItemRules.CheckSolution(item));
LearningItemRules.ConvertToScreen(item);
Assert.Equal((LearningAnswerType.SingleChoice, "1"), (item.AnswerType, item.CorrectAnswer));
Assert.Equal(3, item.Options.Count);
}
[Theory]
[InlineData(LearningAnswerType.Text, null)]
[InlineData(LearningAnswerType.Text, " ")]
[InlineData(LearningAnswerType.SingleChoice, "7")]
public void Items_without_usable_solution_cannot_be_paper_and_stay_unchanged(LearningAnswerType type, string? correct)
{
var item = PaperItem(type, correct, type == LearningAnswerType.SingleChoice ? ["a", "b"] : null);
Assert.False(LearningItemRules.TryConvertToPaper(item, out var error));
Assert.NotNull(error);
Assert.Equal(type, item.AnswerType);
Assert.Equal(correct, item.CorrectAnswer);
}
[Fact]
public void Paper_items_return_to_number_or_text_on_screen()
{
var number = PaperItem(LearningAnswerType.Paper, "12,5");
var text = PaperItem(LearningAnswerType.Paper, "Der Hund bellt");
var toleranced = PaperItem(LearningAnswerType.Paper, "3 cm", tolerance: 1m); // keine reine Zahl: Text, damit die Musterlösung gültig bleibt
LearningItemRules.ConvertToScreen(number);
LearningItemRules.ConvertToScreen(text);
LearningItemRules.ConvertToScreen(toleranced);
Assert.Equal(LearningAnswerType.Number, number.AnswerType);
Assert.Equal(LearningAnswerType.Text, text.AnswerType);
Assert.Equal(LearningAnswerType.Text, toleranced.AnswerType);
Assert.Null(LearningItemRules.CheckSolution(number));
Assert.Null(LearningItemRules.CheckSolution(text));
}
}
@@ -388,6 +388,16 @@ internal sealed class FakeLearningApi : ILearningApi
return Store(detail with { Items = items, Set = detail.Set with { Version = NextVersion() } });
}
public List<(Guid SetId, SetSheetPaperRequest Request)> SheetPaper { get; } = [];
public Task<ApiResult<LearningSetDetailDto>> SetSheetPaperAsync(Guid setId, SetSheetPaperRequest request, CancellationToken ct = default)
{
SheetPaper.Add((setId, request));
var detail = Details[setId];
var items = detail.Items.Select(i => i with { AnswerType = request.Paper ? LearningAnswerType.Paper : LearningAnswerType.Text, Version = NextVersion() }).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) =>
@@ -381,4 +381,43 @@ public sealed class SetEditorTests : LearningTestBase
Assert.Contains("läuft", entries[2].TextContent);
Assert.Null(entries[2].QuerySelector(".fp-set-review-link"));
}
// ---------- Papierblatt (Phase 12a) ----------
[Fact]
public void All_items_can_be_moved_to_paper_in_one_step_and_back()
{
var detail = Api.AddSet("Papier", itemCount: 3);
var cut = Open(detail.Set.Id);
Assert.Empty(cut.FindAll(".fp-set-all-screen"));
cut.Find(".fp-set-all-paper").Click();
cut.WaitForAssertion(() => Assert.Single(Api.SheetPaper));
Assert.True(Api.SheetPaper[0].Request.Paper);
Assert.Equal(detail.Set.Version, Api.SheetPaper[0].Request.Version);
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll(".fp-set-item-meta").Count(m => m.TextContent.Contains("Papier (Foto)"))));
Assert.Empty(cut.FindAll(".fp-set-all-paper"));
cut.Find(".fp-set-all-screen").Click();
cut.WaitForAssertion(() => Assert.Equal(2, Api.SheetPaper.Count));
Assert.False(Api.SheetPaper[1].Request.Paper);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-set-all-screen")));
}
[Fact]
public void Paper_actions_are_hidden_for_locked_sheets()
{
var locked = Api.AddSet("Gesperrt", LearningSetStatus.Submitted, itemCount: 2, attempts: 1);
var cut = Open(locked.Set.Id);
Assert.Empty(cut.FindAll(".fp-set-all-paper"));
}
[Fact]
public void Paper_actions_are_hidden_for_empty_sheets()
{
var empty = Api.AddSet("Leer", itemCount: 0);
var cut = Open(empty.Set.Id);
Assert.Empty(cut.FindAll(".fp-set-all-paper"));
}
}
@@ -1,3 +1,4 @@
using FamilyPlanner.Web.Client.Components.Meals;
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Meals;
@@ -433,4 +434,115 @@ public sealed class MealsPageTests : BunitContext
public Task<AuthResult> ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
// ---------- Zutatenliste (Phase 12a) ----------
[Theory]
[InlineData("500 g Hackfleisch", "500 g", "Hackfleisch")]
[InlineData("2 Paprika", "2", "Paprika")]
[InlineData("Salz nach Geschmack", "", "Salz nach Geschmack")]
[InlineData("Butter 50 g", "", "Butter 50 g")]
[InlineData("1,5 l Milch", "1,5 l", "Milch")]
public void Ingredients_are_split_into_amount_and_name_without_losing_text(string stored, string amount, string name)
{
Assert.Equal((amount, name), MealFormat.SplitIngredient(stored));
Assert.Equal(stored, MealFormat.JoinIngredient(amount, name));
}
[Fact]
public void Ingredients_are_edited_as_rows_and_saved_in_the_old_text_format()
{
var entry = _api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "Salz nach Geschmack", "1 Dose Bohnen"]);
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-meal-list .fp-meal-edit").Click();
cut.WaitForElement(".fp-ingredient-row");
Assert.Equal(3, cut.FindAll(".fp-ingredient-row").Count);
Assert.Equal("500 g", cut.FindAll(".fp-ingredient-amount input")[0].GetAttribute("value"));
Assert.Equal("Hackfleisch", cut.FindAll(".fp-ingredient-name input")[0].GetAttribute("value"));
Assert.Equal("Salz nach Geschmack", cut.FindAll(".fp-ingredient-name input")[1].GetAttribute("value"));
cut.FindAll(".fp-ingredient-remove")[2].Click(); // Bohnen weg
cut.Find(".fp-ingredient-add").Click();
var rows = cut.FindAll(".fp-ingredient-row");
Assert.Equal(3, rows.Count);
cut.FindAll(".fp-ingredient-amount input")[2].Change("2");
cut.FindAll(".fp-ingredient-name input")[2].Change("Paprika");
cut.FindAll(".fp-ingredient-amount input")[0].Change("600 g");
cut.Find("#meal-entry-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
Assert.Equal(["600 g Hackfleisch", "Salz nach Geschmack", "2 Paprika"], _api.Saved[0].Ingredients);
Assert.Equal(entry.Id, _api.Entries.Single().Id);
}
[Fact]
public void Ingredient_rows_without_text_are_dropped_on_save()
{
_api.Seed(Today, "Mittagessen", "Suppe");
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-meal-list .fp-meal-edit").Click();
cut.WaitForElement("#meal-entry-form");
cut.Find(".fp-ingredient-add").Click();
cut.Find("#meal-entry-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.Saved));
Assert.Empty(_api.Saved[0].Ingredients!);
}
[Fact]
public void Single_ingredient_goes_to_the_default_shopping_list_with_feedback()
{
var entry = _api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "1 Dose Bohnen"]);
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-meal-list .fp-meal-edit").Click();
cut.WaitForElement(".fp-ingredient-cart");
cut.FindAll(".fp-ingredient-cart")[1].Click();
cut.WaitForAssertion(() => Assert.Contains("„1 Dose Bohnen“ steht jetzt auf „Supermarkt“", cut.Markup));
var (id, request) = Assert.Single(_api.ToShopping);
Assert.Equal(entry.Id, id);
Assert.Equal(FakeShoppingApi.SupermarketId, request.ListId);
Assert.Equal(["1 Dose Bohnen"], request.Ingredients);
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll(".fp-ingredient-row")[1].GetAttribute("data-onlist")));
Assert.True(cut.FindAll(".fp-ingredient-cart")[1].HasAttribute("disabled")); // nicht doppelt
Assert.False(cut.FindAll(".fp-ingredient-cart")[0].HasAttribute("disabled"));
}
[Fact]
public void Ingredients_already_open_on_the_default_list_are_marked_and_cannot_be_added_again()
{
_shopping.Seed("Hackfleisch", "Fleisch");
_shopping.Seed("Bohnen", "Konserven", done: true); // erledigt zählt nicht
_api.Seed(Today, "Mittagessen", "Chili", ["500 g Hackfleisch", "1 Dose Bohnen"]);
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-meal-list .fp-meal-edit").Click();
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll(".fp-ingredient-row")[0].GetAttribute("data-onlist")));
Assert.Equal("false", cut.FindAll(".fp-ingredient-row")[1].GetAttribute("data-onlist"));
Assert.True(cut.FindAll(".fp-ingredient-cart")[0].HasAttribute("disabled"));
Assert.False(cut.FindAll(".fp-ingredient-cart")[1].HasAttribute("disabled"));
}
[Fact]
public void New_entries_can_not_add_to_the_shopping_basket_before_they_are_saved()
{
SignInAs(Roles.Family);
var cut = Render<MealsPageHost>();
cut.Find(".fp-meal-list .fp-meal-add").Click();
cut.WaitForElement("#meal-entry-form");
cut.Find(".fp-ingredient-add").Click();
cut.FindAll(".fp-ingredient-name input")[0].Change("Mehl");
Assert.True(cut.Find(".fp-ingredient-cart").HasAttribute("disabled"));
Assert.Contains("sobald das Gericht gespeichert ist", cut.Find(".fp-ingredient-hint").TextContent);
Assert.Empty(_api.ToShopping);
}
}