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:
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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user