feat: photo upload for paper tasks, photo review and AI photo grading UI
PhotoUpload component (camera and file picker, preview, replace/remove), photo next to task and model solution in AttemptReview, parent-triggered AI grading button visible only when the status offers photo grading, retention setting in the admin settings editor, bUnit coverage. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
225f02a18b
commit
32091a825e
12 files changed
+776
-8
No files matched your search
@@ -48,6 +48,9 @@ else
|
||||
<MudText Typo="Typo.subtitle2">Aufgaben & Ämtchen</MudText>
|
||||
<MudSwitch T="bool" @bind-Value="_model.ChorePointsEnabled" Label="Punktesystem für Ämtchen" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_model.ChildTasksRequireApproval" Label="Erledigte Kinder-Aufgaben von Eltern bestätigen lassen" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-3">Lernaufgaben</MudText>
|
||||
<MudNumericField T="int" @bind-Value="_model.LearningPhotoRetentionDays" Label="Lernfotos aufbewahren (Tage nach der Prüfung)"
|
||||
Min="1" Max="365" Variant="Variant.Outlined" Margin="Margin.Dense" id="learning-photo-retention" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-3">Gäste</MudText>
|
||||
<MudSwitch T="bool" @bind-Value="_model.GuestsCanEditShoppingList" Label="Gäste dürfen die Einkaufsliste ergänzen/abhaken" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_model.GuestChatEnabled" Label="Gäste dürfen den Familienchat nutzen" Color="Color.Primary" />
|
||||
@@ -133,6 +136,8 @@ else
|
||||
|
||||
public bool GuestChatEnabled { get; set; }
|
||||
|
||||
public int LearningPhotoRetentionDays { get; set; } = 30;
|
||||
|
||||
public static SettingsModel From(FamilySettingsDto s) => new()
|
||||
{
|
||||
FamilyName = s.FamilyName,
|
||||
@@ -144,10 +149,11 @@ else
|
||||
ChildTasksRequireApproval = s.ChildTasksRequireApproval,
|
||||
GuestsCanEditShoppingList = s.GuestsCanEditShoppingList,
|
||||
GuestChatEnabled = s.GuestChatEnabled,
|
||||
LearningPhotoRetentionDays = s.LearningPhotoRetentionDays,
|
||||
};
|
||||
|
||||
public FamilySettingsDto ToDto() => new(
|
||||
FamilyName.Trim(), TimeZoneId, MealPlanDays, MealTypes, TaskCategories,
|
||||
ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled);
|
||||
ChorePointsEnabled, ChildTasksRequireApproval, GuestsCanEditShoppingList, GuestChatEnabled, LearningPhotoRetentionDays);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
@implements IDisposable
|
||||
@inject ILearningApi Api
|
||||
@inject ILiveConnection Live
|
||||
@inject IAiApi Ai
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* Eltern prüfen einen abgegebenen Versuch: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen oder erneut öffnen. *@
|
||||
@@ -68,6 +70,19 @@
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (CanAiGrade(review))
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-3 mb-3 fp-review-ai">
|
||||
<MudText Typo="Typo.subtitle2" HtmlTag="h2">KI-Vorschlag für die Fotos</MudText>
|
||||
<MudText Typo="Typo.body2" Class="mb-2">
|
||||
Die KI liest die Fotos und schlägt eine Bewertung mit Sicherheitsangabe vor. Bei geringer Sicherheit bleibt die Aufgabe offen. Sie bestätigen das Ergebnis immer selbst.
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.AutoAwesome" OnClick="AiGradeAsync" Disabled="_busy" Class="fp-review-ai-button">
|
||||
@(_busy && _aiRunning ? "KI prüft …" : "Fotos von der KI prüfen lassen")
|
||||
</MudButton>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
<ol class="fp-review-answers">
|
||||
@foreach (var answer in review.Answers)
|
||||
{
|
||||
@@ -83,14 +98,58 @@
|
||||
</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 (item.AnswerType == LearningAnswerType.Paper)
|
||||
{
|
||||
<div class="fp-review-paper">
|
||||
<div class="fp-review-photo" data-photo-state="@item.PhotoState.ToString().ToLowerInvariant()">
|
||||
@switch (item.PhotoState)
|
||||
{
|
||||
case LearningPhotoState.Available:
|
||||
<a href="@LearningPhotoUrls.Photo(review.AttemptId, item.ItemId, item.PhotoAt)" target="_blank" rel="noopener" aria-label="Foto in voller Größe öffnen">
|
||||
<img class="fp-review-photo-img" src="@LearningPhotoUrls.Photo(review.AttemptId, item.ItemId, item.PhotoAt)"
|
||||
alt="@($"Foto der Lösung von {review.ChildName} zu Aufgabe {item.Position + 1}")" loading="lazy" />
|
||||
</a>
|
||||
break;
|
||||
case LearningPhotoState.Purged:
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="fp-review-photo-purged">Das Foto wurde nach Ablauf der Aufbewahrungsfrist gelöscht.</MudAlert>
|
||||
break;
|
||||
default:
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="fp-review-photo-missing">Es wurde kein Foto abgegeben.</MudAlert>
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
<div class="fp-review-paper-solution">
|
||||
<p class="fp-review-correct">Musterlösung: <strong>@item.CorrectAnswer</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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 (item.Grader == LearningGrader.Ai && item.AiConfidence is { } confidence)
|
||||
{
|
||||
<div class="fp-review-confidence">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@(confidence >= LearningAiGrading.MinConfidence ? Color.Success : Color.Warning)">
|
||||
Sicherheit der KI: @confidence %
|
||||
</MudChip>
|
||||
@if (item.Verdict == LearningVerdict.Open)
|
||||
{
|
||||
<span>Die KI ist unsicher – bitte selbst prüfen.</span>
|
||||
}
|
||||
else if (!confirmed)
|
||||
{
|
||||
<span>Nur ein Vorschlag – bitte prüfen und bestätigen.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!confirmed)
|
||||
{
|
||||
@@ -157,6 +216,8 @@
|
||||
private bool _missing;
|
||||
private string? _error;
|
||||
private string? _notice;
|
||||
private bool _aiAvailable;
|
||||
private bool _aiRunning;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public Guid AttemptId { get; set; }
|
||||
@@ -169,6 +230,7 @@
|
||||
}
|
||||
|
||||
_loadedId = AttemptId;
|
||||
await LoadAiStatusAsync();
|
||||
Live.LearningChanged -= OnLearningChanged;
|
||||
Live.LearningChanged += OnLearningChanged;
|
||||
await LoadAsync();
|
||||
@@ -212,13 +274,78 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Der Knopf erscheint nur, wenn die Foto-Prüfung eingerichtet ist (Anbieter für diesen Zweck, Bilder erlaubt) – GET api/ai/status.</summary>
|
||||
private async Task LoadAiStatusAsync()
|
||||
{
|
||||
var status = await Ai.GetStatusAsync();
|
||||
_aiAvailable = status.Succeeded && status.Value is { CanGradeImages: true };
|
||||
}
|
||||
|
||||
private bool CanAiGrade(LearningAttemptReviewDto review) =>
|
||||
_aiAvailable
|
||||
&& review.Status == LearningAttemptStatus.Submitted
|
||||
&& review.Answers.Any(a => a.AnswerType == LearningAnswerType.Paper && a.PhotoState == LearningPhotoState.Available && a.Grader != LearningGrader.Parent);
|
||||
|
||||
private async Task AiGradeAsync()
|
||||
{
|
||||
if (_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var confirmed = await DialogService.ShowMessageBoxAsync(
|
||||
"Fotos von der KI prüfen lassen?",
|
||||
"Die Fotos dieses Versuchs werden zur Prüfung an den eingerichteten KI-Anbieter gesendet. Liegt der Anbieter außerhalb Ihres Heimnetzes, verlassen die Fotos der Kinderhefte das Heimnetz. "
|
||||
+ "Die KI liefert nur einen Vorschlag; bei Zweifeln bleibt die Aufgabe offen. Bewertungen, die Sie selbst vergeben haben, bleiben unverändert.",
|
||||
yesText: "Ja, prüfen lassen", cancelText: "Abbrechen");
|
||||
if (confirmed != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_aiRunning = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var result = await Api.AiGradeAsync(AttemptId, new LearningAiGradeRequest(true));
|
||||
if (result.Succeeded && result.Value is { } graded)
|
||||
{
|
||||
_review = graded.Attempt;
|
||||
_reasons.Clear();
|
||||
Snackbar.Add(
|
||||
graded.NeedsReview > 0
|
||||
? $"KI-Vorschlag für {graded.Graded} Aufgabe(n) – {graded.NeedsReview} davon unsicher und weiter offen."
|
||||
: $"KI-Vorschlag für {graded.Graded} Aufgabe(n) liegt vor.",
|
||||
Severity.Info);
|
||||
if (graded.Message is not null || graded.Failed > 0)
|
||||
{
|
||||
_error = graded.Message ?? $"{graded.Failed} Foto(s) konnten nicht ausgewertet werden – bitte selbst prüfen.";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Die KI-Prüfung hat nicht geklappt.";
|
||||
if (result.Problem?.Status == 409)
|
||||
{
|
||||
await ReloadQuietAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
_aiRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
_reasons.TryGetValue(item.ItemId, out var reason) ? reason : item.Grader == LearningGrader.Ai ? null : item.Reason; // KI-Begründung ist nur für Eltern, nie als Kommentar an das Kind
|
||||
|
||||
private void SetReason(LearningReviewAnswerDto item, string? value) => _reasons[item.ItemId] = value;
|
||||
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
Variant="Variant.Outlined" InputMode="@(InputMode.@decimal)" Disabled="_submitting" Class="fp-learning-input fp-learning-number"
|
||||
InputId="@InputId" Clearable="false" />
|
||||
break;
|
||||
case LearningAnswerType.Paper:
|
||||
<PhotoUpload AttemptId="AttemptId" ItemId="item.ItemId" HasPhoto="@(Value(item.ItemId) == LearningPhotoRules.AnswerMarker)"
|
||||
PhotoAt="@_photoAt.GetValueOrDefault(item.ItemId)" Disabled="_submitting"
|
||||
Changed="r => PhotoChangedAsync(item, r)" Conflict="LoadAsync" />
|
||||
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" />
|
||||
@@ -186,6 +191,7 @@
|
||||
private readonly Dictionary<Guid, string?> _values = [];
|
||||
private readonly Dictionary<Guid, string?> _saved = [];
|
||||
private readonly Dictionary<Guid, LearningVerdict?> _verdicts = [];
|
||||
private readonly Dictionary<Guid, DateTimeOffset?> _photoAt = [];
|
||||
private readonly HashSet<Guid> _hints = [];
|
||||
private readonly HashSet<TimeSpan> _announced = [];
|
||||
private LearningAttemptDto? _attempt;
|
||||
@@ -253,8 +259,10 @@
|
||||
_values.Clear();
|
||||
_saved.Clear();
|
||||
_verdicts.Clear();
|
||||
_photoAt.Clear();
|
||||
foreach (var item in attempt.Items)
|
||||
{
|
||||
_photoAt[item.ItemId] = item.PhotoAt;
|
||||
_values[item.ItemId] = item.Answer;
|
||||
_saved[item.ItemId] = item.Answer;
|
||||
if (attempt.Mode == LearningMode.Practice && item.Feedback is { } feedback)
|
||||
@@ -317,6 +325,18 @@
|
||||
return SaveAsync(item.ItemId);
|
||||
}
|
||||
|
||||
/// <summary>Foto gespeichert (Ergebnis) oder entfernt (<c>null</c>): Serverstand übernehmen – Papieraufgaben gelten mit Foto als beantwortet.</summary>
|
||||
private Task PhotoChangedAsync(LearningAttemptItemDto item, SaveAnswerResultDto? result)
|
||||
{
|
||||
var value = result is null ? null : LearningPhotoRules.AnswerMarker;
|
||||
_values[item.ItemId] = value;
|
||||
_saved[item.ItemId] = value;
|
||||
_photoAt[item.ItemId] = result?.ServerNow;
|
||||
_verdicts[item.ItemId] = result is not null && _attempt?.Mode == LearningMode.Practice ? result.Verdict : null;
|
||||
_saveState = result is null ? SaveState.Idle : SaveState.Saved;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task TextChangedAsync(LearningAttemptItemDto item, string? value)
|
||||
{
|
||||
_values[item.ItemId] = value;
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
HelperText="Mehrere richtige Schreibweisen mit | trennen. Weicht die Antwort ab, entscheiden die Eltern."
|
||||
Error="@HasError("CorrectAnswer")" ErrorText="@ErrorFor("CorrectAnswer")" />
|
||||
break;
|
||||
case LearningAnswerType.Paper:
|
||||
<MudTextField @bind-Value="_correct" Label="Musterlösung" InputId="item-correct" Variant="Variant.Outlined" Lines="2"
|
||||
HelperText="Das Kind löst auf Papier und lädt ein Foto hoch. Die Musterlösung sehen nur Eltern (und die KI-Prüfung) als Maßstab."
|
||||
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>
|
||||
@@ -103,7 +108,7 @@
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice];
|
||||
private static readonly LearningAnswerType[] Types = [LearningAnswerType.Number, LearningAnswerType.Text, LearningAnswerType.SingleChoice, LearningAnswerType.Paper];
|
||||
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance? Dialog { get; set; }
|
||||
@@ -143,7 +148,7 @@
|
||||
private void Load(LearningItemDto item)
|
||||
{
|
||||
_question = item.Question;
|
||||
_type = item.AnswerType == LearningAnswerType.Paper ? LearningAnswerType.Text : item.AnswerType;
|
||||
_type = item.AnswerType;
|
||||
_tolerance = item.NumberTolerance?.ToString(CultureInfo.GetCultureInfo("de-DE"));
|
||||
if (item.AnswerType == LearningAnswerType.SingleChoice)
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ public static class LearningFormat
|
||||
LearningAnswerType.Number => "Zahl",
|
||||
LearningAnswerType.Text => "Text",
|
||||
LearningAnswerType.SingleChoice => "Auswahl",
|
||||
LearningAnswerType.Paper => "Papier",
|
||||
LearningAnswerType.Paper => "Papier (Foto)",
|
||||
_ => type.ToString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
@using FamilyPlanner.Domain.Learning
|
||||
@using FamilyPlanner.Shared.Learning
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@inject ILearningApi Api
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* Foto der Papierlösung: Kamera oder Datei, Vorschau des gespeicherten Fotos, Ersetzen oder Entfernen (nur vor der Abgabe). *@
|
||||
<div class="fp-photo-upload" data-item-id="@ItemId">
|
||||
@if (HasPhoto)
|
||||
{
|
||||
<img class="fp-photo-preview" src="@LearningPhotoUrls.Photo(AttemptId, ItemId, PhotoAt)" alt="Dein Foto der Lösung" />
|
||||
<p class="fp-photo-state" role="status" aria-live="polite">@(_busy ? "Wird hochgeladen …" : "Foto gespeichert ✓")</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<label class="@PickClass(false)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PhotoCamera" Size="Size.Small" />
|
||||
<span>Neues Foto</span>
|
||||
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" capture="environment" disabled="@(_busy || Disabled)"
|
||||
class="fp-photo-input fp-photo-replace" aria-label="Neues Foto aufnehmen" />
|
||||
</label>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Error" Size="Size.Large" StartIcon="@Icons.Material.Filled.Delete" Disabled="@(_busy || Disabled)"
|
||||
OnClick="RemoveAsync" Class="fp-photo-remove">Foto entfernen</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="fp-photo-help">Löse die Aufgabe auf Papier und lade ein Foto davon hoch. Achte auf gutes Licht und eine gut lesbare Schrift.</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<label class="@PickClass(true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PhotoCamera" Size="Size.Small" />
|
||||
<span>Foto aufnehmen</span>
|
||||
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" capture="environment" disabled="@(_busy || Disabled)"
|
||||
class="fp-photo-input fp-photo-camera" aria-label="Foto aufnehmen" />
|
||||
</label>
|
||||
<label class="@PickClass(false)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Image" Size="Size.Small" />
|
||||
<span>Datei wählen</span>
|
||||
<InputFile OnChange="OnPickedAsync" accept="image/jpeg,image/png,image/webp" disabled="@(_busy || Disabled)"
|
||||
class="fp-photo-input fp-photo-file" aria-label="Foto aus Datei wählen" />
|
||||
</label>
|
||||
</div>
|
||||
@if (_busy)
|
||||
{
|
||||
<p class="fp-photo-state" role="status" aria-live="polite">Wird hochgeladen …</p>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2 fp-photo-error" role="alert">@_error</MudAlert>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Ab dieser Größe wird im Browser verkleinert (Handyfotos sind oft größer als das Limit).</summary>
|
||||
private const long ShrinkAbove = 2 * 1024 * 1024;
|
||||
|
||||
private const int MaxSide = 2400;
|
||||
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public Guid AttemptId { get; set; }
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool HasPhoto { get; set; }
|
||||
|
||||
/// <summary>Zeitpunkt der letzten Speicherung – dient dem Browser als Versionsmarke, damit ein ersetztes Foto neu geladen wird.</summary>
|
||||
[Parameter]
|
||||
public DateTimeOffset? PhotoAt { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
/// <summary>Foto gespeichert (Ergebnis des Servers) bzw. entfernt (<c>null</c>).</summary>
|
||||
[Parameter]
|
||||
public EventCallback<SaveAnswerResultDto?> Changed { get; set; }
|
||||
|
||||
/// <summary>Der Server meldet Abgabe oder Zeitablauf (409): der Versuch muss neu geladen werden.</summary>
|
||||
[Parameter]
|
||||
public EventCallback Conflict { get; set; }
|
||||
|
||||
private static string PickClass(bool primary) =>
|
||||
$"fp-photo-pick {(primary ? "is-primary" : string.Empty)}";
|
||||
|
||||
private async Task OnPickedAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
if (_busy || args.FileCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var file = args.File;
|
||||
if (file.Size > ShrinkAbove && file.ContentType is "image/jpeg" or "image/png" or "image/webp")
|
||||
{
|
||||
try
|
||||
{
|
||||
file = await file.RequestImageFileAsync("image/jpeg", MaxSide, MaxSide);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// Verkleinern ist nur eine Hilfe: ohne Browserunterstützung wird das Original geprüft.
|
||||
}
|
||||
}
|
||||
|
||||
if (file.Size > LearningPhotoRules.MaxBytes)
|
||||
{
|
||||
_error = $"Das Foto ist zu groß (höchstens {LearningPhotoRules.MaxBytes / 1024 / 1024} MB).";
|
||||
return;
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream(LearningPhotoRules.MaxBytes);
|
||||
var result = await Api.UploadPhotoAsync(AttemptId, ItemId, stream, file.Name, file.ContentType);
|
||||
if (result.Succeeded && result.Value is { } saved)
|
||||
{
|
||||
await Changed.InvokeAsync(saved);
|
||||
}
|
||||
else if (result.Problem?.Status == 409)
|
||||
{
|
||||
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
|
||||
await Conflict.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Das Foto konnte nicht hochgeladen werden. Bitte noch einmal versuchen.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveAsync()
|
||||
{
|
||||
if (_busy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var result = await Api.DeletePhotoAsync(AttemptId, ItemId);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await Changed.InvokeAsync(null);
|
||||
}
|
||||
else if (result.Problem?.Status == 409)
|
||||
{
|
||||
Snackbar.Add(result.Problem.Detail ?? "Der Versuch ist bereits abgegeben.", Severity.Warning);
|
||||
await Conflict.InvokeAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = result.Problem?.Message ?? "Das Foto konnte nicht entfernt werden.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,12 @@ public interface ILearningApi
|
||||
|
||||
Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lässt die KI die Fotos eines abgegebenen Versuchs prüfen (nur mit <c>AiUse</c>; die Oberfläche bietet es nur an, wenn <c>GET api/ai/status</c> die Foto-Prüfung meldet).
|
||||
/// Liefert Vorschläge – bestätigt wird weiter über <see cref="ConfirmAsync"/>.
|
||||
/// </summary>
|
||||
Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default);
|
||||
|
||||
// ---- Kinder ----
|
||||
Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -61,6 +67,11 @@ public interface ILearningApi
|
||||
Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Foto der Papierlösung hochladen oder ersetzen. Bei 409 (abgegeben/abgelaufen) enthält <see cref="ApiResult{T}.Problem"/> die Meldung.</summary>
|
||||
Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class LearningApi(HttpClient http) : ILearningApi
|
||||
@@ -118,6 +129,9 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
|
||||
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) =>
|
||||
Send<List<UserDirectoryEntry>>(() => http.GetAsync("api/users", ct), ct);
|
||||
|
||||
public Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default) =>
|
||||
Send<LearningAiGradeResultDto>(() => http.PostAsJsonAsync($"{Base}/attempts/{attemptId}/ai-grade", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default) =>
|
||||
Send<List<MyLearningSetDto>>(() => http.GetAsync($"{Base}/my", ct), ct);
|
||||
|
||||
@@ -133,6 +147,19 @@ public sealed class LearningApi(HttpClient http) : ILearningApi
|
||||
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default) =>
|
||||
Send<LearningAttemptDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/submit", null, ct), ct);
|
||||
|
||||
public async Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(
|
||||
Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new StreamContent(content);
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
|
||||
form.Add(file, "file", string.IsNullOrWhiteSpace(fileName) ? "foto" : fileName);
|
||||
return await Send<SaveAnswerResultDto>(() => http.PostAsync($"{Base}/my/attempts/{attemptId}/items/{itemId}/photo", form, ct), ct);
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync(() => http.DeleteAsync($"{Base}/my/attempts/{attemptId}/items/{itemId}/photo", 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.
|
||||
|
||||
@@ -1799,3 +1799,99 @@ a.fp-learning-title:hover {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lernaufgaben Phase 10c: Foto der Papierlösung */
|
||||
.fp-photo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fp-photo-help,
|
||||
.fp-photo-state {
|
||||
margin: 0;
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.fp-photo-preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--mud-palette-lines-default);
|
||||
object-fit: contain;
|
||||
background: var(--mud-palette-surface);
|
||||
}
|
||||
|
||||
.fp-photo-pick {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 56px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 14px;
|
||||
border: 2px solid var(--mud-palette-primary);
|
||||
background: var(--mud-palette-surface);
|
||||
color: var(--mud-palette-primary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fp-photo-pick.is-primary {
|
||||
background: var(--mud-palette-primary);
|
||||
color: var(--mud-palette-primary-text);
|
||||
}
|
||||
|
||||
.fp-photo-pick:focus-within {
|
||||
outline: 3px solid var(--mud-palette-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Das echte Eingabefeld füllt den ganzen Knopf (gut mit dem Finger zu treffen), bleibt aber unsichtbar. */
|
||||
.fp-photo-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fp-review-paper {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fp-review-photo-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.fp-review-confidence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.fp-review-paper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fp-photo-pick {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ internal sealed class FakeAiApi : IAiApi
|
||||
|
||||
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiUsageOverviewDto>.Ok(Usage));
|
||||
|
||||
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiStatusDto>.Ok(new AiStatusDto(true, true, false)));
|
||||
public AiStatusDto Status { get; set; } = new(true, true, false);
|
||||
|
||||
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) => Task.FromResult(ApiResult<AiStatusDto>.Ok(Status));
|
||||
}
|
||||
|
||||
public sealed class AiManagementTests : BunitContext
|
||||
|
||||
@@ -382,6 +382,52 @@ internal sealed class FakeLearningApi : ILearningApi
|
||||
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, request.Value is not null, verdict, attempt.DeadlineAt, ServerNow()));
|
||||
}
|
||||
|
||||
// ---- Fotos (Phase 10c) ----
|
||||
public List<(Guid Attempt, Guid Item, string FileName, string ContentType, int Length)> Uploaded { get; } = [];
|
||||
|
||||
public List<(Guid Attempt, Guid Item)> PhotosDeleted { get; } = [];
|
||||
|
||||
public List<(Guid Attempt, LearningAiGradeRequest Request)> AiGraded { get; } = [];
|
||||
|
||||
/// <summary>Antwort auf einen Foto-Upload; Standard: gespeichert.</summary>
|
||||
public Func<ApiResult<SaveAnswerResultDto>>? UploadBehavior { get; set; }
|
||||
|
||||
/// <summary>Antwort auf die KI-Prüfung; Standard: kein Ergebnis (Test setzt es).</summary>
|
||||
public Func<Guid, ApiResult<LearningAiGradeResultDto>>? AiGradeBehavior { get; set; }
|
||||
|
||||
public async Task<ApiResult<SaveAnswerResultDto>> UploadPhotoAsync(
|
||||
Guid attemptId, Guid itemId, Stream content, string fileName, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, ct);
|
||||
Uploaded.Add((attemptId, itemId, fileName, contentType, (int)buffer.Length));
|
||||
if (UploadBehavior is { } behavior)
|
||||
{
|
||||
return behavior();
|
||||
}
|
||||
|
||||
var attempt = Attempts[attemptId];
|
||||
Attempts[attemptId] = attempt with
|
||||
{
|
||||
Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = LearningPhotoRules.AnswerMarker, HasPhoto = true, PhotoAt = ServerNow() } : i).ToList(),
|
||||
};
|
||||
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, true, null, attempt.DeadlineAt, ServerNow()));
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeletePhotoAsync(Guid attemptId, Guid itemId, CancellationToken ct = default)
|
||||
{
|
||||
PhotosDeleted.Add((attemptId, itemId));
|
||||
var attempt = Attempts[attemptId];
|
||||
Attempts[attemptId] = attempt with { Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = null, HasPhoto = false, PhotoAt = null } : i).ToList() };
|
||||
return Task.FromResult(ApiResult<bool>.Ok(true));
|
||||
}
|
||||
|
||||
public Task<ApiResult<LearningAiGradeResultDto>> AiGradeAsync(Guid attemptId, LearningAiGradeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
AiGraded.Add((attemptId, request));
|
||||
return Task.FromResult(AiGradeBehavior?.Invoke(attemptId) ?? ApiResult<LearningAiGradeResultDto>.Fail(new ApiProblem { Status = 409, Detail = "nicht eingerichtet" }));
|
||||
}
|
||||
|
||||
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default)
|
||||
{
|
||||
Submitted.Add(attemptId);
|
||||
|
||||
@@ -2,6 +2,7 @@ using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Web.Client.Auth;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using FamilyPlanner.Web.Tests.Admin;
|
||||
using FamilyPlanner.Web.Tests.Shopping;
|
||||
using FamilyPlanner.Web.Tests.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -16,6 +17,9 @@ public abstract class LearningTestBase : BunitContext
|
||||
|
||||
internal FakeLiveConnection Live { get; } = new();
|
||||
|
||||
/// <summary>KI-Status für die Eltern-Prüfansicht; Standard: Foto-Prüfung nicht eingerichtet (kein KI-Knopf).</summary>
|
||||
internal FakeAiApi Ai { get; } = new();
|
||||
|
||||
internal ManualTimeProvider Time { get; } = new(FakeLearningApi.Now);
|
||||
|
||||
protected LearningTestBase()
|
||||
@@ -24,6 +28,7 @@ public abstract class LearningTestBase : BunitContext
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<ILearningApi>(Api);
|
||||
Services.AddSingleton<ILiveConnection>(Live);
|
||||
Services.AddSingleton<IAiApi>(Ai);
|
||||
Services.AddSingleton<IAuthService, NoopAuthService>();
|
||||
Services.AddSingleton<ApiAuthenticationStateProvider>();
|
||||
Services.AddSingleton<TimeProvider>(Time);
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using FamilyPlanner.Web.Client.Components.Learning;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Learning;
|
||||
|
||||
/// <summary>Papierlösung per Foto (Phase 10c): Upload-Feld im Runner, Anzeige neben Aufgabe und Musterlösung in der Prüfung, KI-Knopf nur bei eingerichteter Foto-Prüfung.</summary>
|
||||
public sealed class PaperPhotoTests : LearningTestBase
|
||||
{
|
||||
private static LearningAttemptDto PaperAttempt(bool withPhoto = false)
|
||||
{
|
||||
var item = FakeLearningApi.AttemptItem(0, "Löse auf Papier: 14 + 29", LearningAnswerType.Paper, 3, answer: withPhoto ? LearningPhotoRules.AnswerMarker : null) with
|
||||
{
|
||||
HasPhoto = withPhoto,
|
||||
PhotoAt = withPhoto ? FakeLearningApi.Now : null,
|
||||
};
|
||||
return FakeLearningApi.NewAttempt("Heft", LearningMode.Practice, [item]);
|
||||
}
|
||||
|
||||
private IRenderedComponent<LearningHost> OpenRunner(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 InputFileContent Png(int length = 8) => InputFileContent.CreateFromBinary(new byte[length], "heft.png", null, "image/png");
|
||||
|
||||
// ---------- Kind ----------
|
||||
|
||||
[Fact]
|
||||
public void Paper_questions_get_camera_and_file_pickers_instead_of_a_text_field()
|
||||
{
|
||||
var cut = OpenRunner(PaperAttempt());
|
||||
|
||||
Assert.Empty(cut.FindAll(".fp-learning-input"));
|
||||
Assert.Single(cut.FindAll(".fp-photo-camera"));
|
||||
Assert.Equal("environment", cut.Find(".fp-photo-camera").GetAttribute("capture")); // öffnet auf dem Handy direkt die Kamera
|
||||
Assert.Single(cut.FindAll(".fp-photo-file"));
|
||||
Assert.Equal("image/jpeg,image/png,image/webp", cut.Find(".fp-photo-file").GetAttribute("accept"));
|
||||
Assert.Empty(cut.FindAll(".fp-photo-preview"));
|
||||
Assert.Equal("0 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uploading_stores_the_photo_through_the_photo_endpoint_shows_a_preview_and_counts_as_answered()
|
||||
{
|
||||
var attempt = PaperAttempt();
|
||||
var cut = OpenRunner(attempt);
|
||||
|
||||
cut.FindComponent<InputFile>().UploadFiles(Png(12));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-photo-preview")));
|
||||
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId, "heft.png", "image/png", 12)], Api.Uploaded);
|
||||
Assert.Empty(Api.Saved); // nie über den Text-Endpunkt
|
||||
var source = cut.Find(".fp-photo-preview").GetAttribute("src")!;
|
||||
Assert.StartsWith($"/api/learning/attempts/{attempt.Id}/items/{attempt.Items[0].ItemId}/photo?v=", source);
|
||||
Assert.Contains("Foto gespeichert", cut.Find(".fp-photo-state").TextContent);
|
||||
Assert.Equal("1 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-photo-camera")); // statt der Auswahl: Ersetzen und Entfernen
|
||||
Assert.Single(cut.FindAll(".fp-photo-replace"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stored_photo_can_be_replaced_or_removed_before_submitting()
|
||||
{
|
||||
var attempt = PaperAttempt(withPhoto: true);
|
||||
var cut = OpenRunner(attempt);
|
||||
Assert.Single(cut.FindAll(".fp-photo-preview"));
|
||||
Assert.Equal("1 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
|
||||
|
||||
cut.FindComponent<InputFile>().UploadFiles(Png(5));
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.Uploaded));
|
||||
|
||||
cut.Find(".fp-photo-remove").Click();
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-photo-preview")));
|
||||
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId)], Api.PhotosDeleted);
|
||||
Assert.Equal("0 von 1 beantwortet", cut.Find(".fp-learning-progress-text").TextContent);
|
||||
Assert.Single(cut.FindAll(".fp-photo-camera"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Too_large_photos_are_rejected_in_the_browser_without_an_upload()
|
||||
{
|
||||
var cut = OpenRunner(PaperAttempt());
|
||||
|
||||
cut.FindComponent<InputFile>().UploadFiles(Png(LearningPhotoRules.MaxBytes + 1));
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("zu groß", cut.Find(".fp-photo-error").TextContent));
|
||||
Assert.Empty(Api.Uploaded);
|
||||
Assert.Empty(cut.FindAll(".fp-photo-preview"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Server_errors_are_shown_without_losing_the_picker()
|
||||
{
|
||||
var cut = OpenRunner(PaperAttempt());
|
||||
Api.UploadBehavior = () => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 400, Detail = "Bitte ein Foto im Format PNG, JPEG oder WebP senden." });
|
||||
|
||||
cut.FindComponent<InputFile>().UploadFiles(Png());
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Format PNG, JPEG oder WebP", cut.Find(".fp-photo-error").TextContent));
|
||||
Assert.Single(cut.FindAll(".fp-photo-camera"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_conflict_after_the_deadline_reloads_the_attempt()
|
||||
{
|
||||
var cut = OpenRunner(PaperAttempt());
|
||||
Api.UploadBehavior = () => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 409, Detail = "Die Zeit ist abgelaufen. Der Versuch wurde abgegeben." });
|
||||
var calls = Api.GetAttemptCalls;
|
||||
|
||||
cut.FindComponent<InputFile>().UploadFiles(Png());
|
||||
|
||||
cut.WaitForAssertion(() => Assert.True(Api.GetAttemptCalls > calls));
|
||||
}
|
||||
|
||||
// ---------- Eltern: Prüfung ----------
|
||||
|
||||
private static LearningReviewAnswerDto PaperAnswer(
|
||||
LearningPhotoState state, LearningVerdict verdict = LearningVerdict.Open, LearningGrader grader = LearningGrader.Auto, int? confidence = null, string? reason = null, int points = 3) =>
|
||||
new(Guid.NewGuid(), 0, "Löse auf Papier: 14 + 29", LearningAnswerType.Paper, [], "43", null, points,
|
||||
state == LearningPhotoState.Available ? LearningPhotoRules.AnswerMarker : null, verdict, grader, verdict == LearningVerdict.Correct ? points : 0, reason,
|
||||
state, state == LearningPhotoState.Available ? FakeLearningApi.Now : null, confidence);
|
||||
|
||||
private LearningAttemptReviewDto Review(LearningAttemptStatus status = LearningAttemptStatus.Submitted, params LearningReviewAnswerDto[] answers)
|
||||
{
|
||||
var score = LearningRules.Score(answers.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
|
||||
var review = new LearningAttemptReviewDto(
|
||||
Guid.NewGuid(), Guid.NewGuid(), "Heft", LearningSubjects.Math, LearningMode.Practice, 10, FakeLearningApi.Mia, "Mia", 1, status,
|
||||
FakeLearningApi.Now.AddMinutes(-30), FakeLearningApi.Now.AddMinutes(-5), false, answers, score.Earned, answers.Sum(a => a.ItemPoints), score.OpenCount, null, 0, null, [1]);
|
||||
Api.Reviews[review.AttemptId] = review;
|
||||
return review;
|
||||
}
|
||||
|
||||
private IRenderedComponent<LearningHost> OpenReview(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 The_review_shows_the_photo_next_to_the_solution_with_a_versioned_address_and_alt_text()
|
||||
{
|
||||
var review = Review(answers: PaperAnswer(LearningPhotoState.Available));
|
||||
var cut = OpenReview(review);
|
||||
|
||||
var image = cut.Find(".fp-review-paper .fp-review-photo-img");
|
||||
Assert.StartsWith($"/api/learning/attempts/{review.AttemptId}/items/{review.Answers[0].ItemId}/photo?v=", image.GetAttribute("src"));
|
||||
Assert.Contains("Mia", image.GetAttribute("alt"));
|
||||
Assert.Contains("43", cut.Find(".fp-review-paper-solution").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-review-given")); // kein Antworttext bei Papieraufgaben
|
||||
Assert.NotEmpty(cut.FindAll(".fp-review-actions")); // manuell bewerten
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deleted_and_missing_photos_are_explained()
|
||||
{
|
||||
var review = Review(answers: [PaperAnswer(LearningPhotoState.Purged), PaperAnswer(LearningPhotoState.None)]);
|
||||
var cut = OpenReview(review);
|
||||
|
||||
Assert.Empty(cut.FindAll(".fp-review-photo-img"));
|
||||
Assert.Contains("nach Ablauf der Aufbewahrungsfrist gelöscht", cut.Find(".fp-review-photo-purged").TextContent);
|
||||
Assert.Contains("kein Foto abgegeben", cut.Find(".fp-review-photo-missing").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ai_button_is_hidden_when_the_status_does_not_offer_photo_grading()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, false);
|
||||
|
||||
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available))).FindAll(".fp-review-ai-button"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ai_button_appears_when_the_status_offers_photo_grading()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, true);
|
||||
|
||||
Assert.Single(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available))).FindAll(".fp-review-ai-button"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ai_button_is_hidden_when_every_photo_is_gone()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, true);
|
||||
|
||||
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Purged))).FindAll(".fp-review-ai-button"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ai_button_is_hidden_when_parents_already_graded_the_photo()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, true);
|
||||
|
||||
Assert.Empty(OpenReview(Review(answers: PaperAnswer(LearningPhotoState.Available, LearningVerdict.Correct, LearningGrader.Parent))).FindAll(".fp-review-ai-button"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_ai_button_is_hidden_after_confirmation()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, true);
|
||||
|
||||
Assert.Empty(OpenReview(Review(LearningAttemptStatus.Reviewed, PaperAnswer(LearningPhotoState.Available, LearningVerdict.Correct))).FindAll(".fp-review-ai-button"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Running_the_ai_asks_for_the_privacy_notice_first_and_shows_suggestion_with_confidence()
|
||||
{
|
||||
Ai.Status = new AiStatusDto(true, true, true);
|
||||
var review = Review(answers: PaperAnswer(LearningPhotoState.Available));
|
||||
var graded = review with
|
||||
{
|
||||
Answers = [review.Answers[0] with { Verdict = LearningVerdict.Correct, Grader = LearningGrader.Ai, AwardedPoints = 3, AiConfidence = 92, Reason = "KI: Die Rechnung stimmt." }],
|
||||
EarnedPoints = 3,
|
||||
OpenCount = 0,
|
||||
};
|
||||
Api.AiGradeBehavior = _ => ApiResult<LearningAiGradeResultDto>.Ok(new LearningAiGradeResultDto(1, 0, 0, null, graded));
|
||||
var cut = OpenReview(review);
|
||||
|
||||
var click = cut.InvokeAsync(() => cut.Find(".fp-review-ai-button").Click());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Fotos von der KI prüfen lassen?", cut.Markup));
|
||||
Assert.Contains("verlassen die Fotos der Kinderhefte das Heimnetz", cut.Markup);
|
||||
Assert.Empty(Api.AiGraded);
|
||||
|
||||
cut.FindAll("button").First(b => Text(b) == "Abbrechen").Click();
|
||||
cut.WaitForAssertion(() => Assert.DoesNotContain("Fotos von der KI prüfen lassen?", cut.Markup));
|
||||
Assert.Empty(Api.AiGraded);
|
||||
|
||||
var again = cut.InvokeAsync(() => cut.Find(".fp-review-ai-button").Click());
|
||||
cut.WaitForAssertion(() => Assert.Contains("Fotos von der KI prüfen lassen?", cut.Markup));
|
||||
cut.FindAll("button").First(b => Text(b) == "Ja, prüfen lassen").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.AiGraded));
|
||||
Assert.True(Api.AiGraded[0].Request.AcknowledgePrivacy);
|
||||
cut.WaitForAssertion(() => Assert.Contains("Sicherheit der KI: 92 %", cut.Find(".fp-review-confidence").TextContent));
|
||||
Assert.Contains("von der KI vorgeschlagen", cut.Find(".fp-review-grader").TextContent);
|
||||
Assert.Contains("Nur ein Vorschlag", cut.Find(".fp-review-confidence").TextContent);
|
||||
Assert.NotEmpty(cut.FindAll(".fp-review-confirm")); // bestätigt wird weiterhin von Hand
|
||||
Assert.True(click.IsCompleted || !click.IsFaulted);
|
||||
Assert.True(again.IsCompleted || !again.IsFaulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Low_confidence_stays_open_and_the_ai_reason_is_not_prefilled_as_a_comment_to_the_child()
|
||||
{
|
||||
var review = Review(answers: PaperAnswer(LearningPhotoState.Available, LearningVerdict.Open, LearningGrader.Ai, 40, "KI: unleserlich"));
|
||||
var cut = OpenReview(review);
|
||||
|
||||
Assert.Contains("Die KI ist unsicher", cut.Find(".fp-review-confidence").TextContent);
|
||||
Assert.Contains("KI: unleserlich", cut.Find(".fp-review-grader").TextContent);
|
||||
Assert.Equal(string.Empty, cut.Find(".fp-review-reason input").GetAttribute("value") ?? string.Empty);
|
||||
|
||||
cut.Find(".fp-review-correct-button").Click();
|
||||
cut.WaitForAssertion(() => Assert.Single(Api.Reviewed));
|
||||
Assert.Null(Api.Reviewed[0].Request.Reason); // die KI-Begründung geht nicht als Elternkommentar an das Kind
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user