feat: put a whole learning sheet on paper in one step, also from the AI draft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 21:58:03 +02:00
1 parent 298d765465
commit ac38444e1e
13 files changed
+370 -3

No files matched your search

@@ -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)
@@ -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()
{
@@ -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"));
}
}