test: cover learning API flows, rights, time limit, points and live events
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c72a6f768
commit
cc129eb1e5
8 files changed
+968
-7
No files matched your search
@@ -32,7 +32,7 @@ public sealed class LearningAttemptService(AppDbContext db, ISettingsProvider se
|
||||
if (answer is null)
|
||||
{
|
||||
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = id };
|
||||
attempt.Answers.Add(answer);
|
||||
db.LearningAnswers.Add(answer); // explizit: eine bereits gesetzte Id ließe EF die Entität sonst als vorhanden (Modified) behandeln
|
||||
}
|
||||
|
||||
if (answer.Grader == LearningGrader.Parent)
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace FamilyPlanner.Api.Features.Learning;
|
||||
/// <summary>Elternseite der Lernaufgaben (<c>LearningManage</c>): Blätter und Aufgaben, Zuweisen, Prüf-Warteschlange, Bestätigen (ADR-0021).</summary>
|
||||
internal static class LearningManageEndpoints
|
||||
{
|
||||
private const int MaxReviewQueue = 100;
|
||||
private const int MaxReviewQueue = 200;
|
||||
|
||||
public static void Map(RouteGroupBuilder group)
|
||||
{
|
||||
@@ -229,7 +229,8 @@ internal static class LearningManageEndpoints
|
||||
|
||||
var item = LearningValidation.ToItem(request);
|
||||
item.SortOrder = set.Items.Count == 0 ? 0 : set.Items.Max(i => i.SortOrder) + 1;
|
||||
set.Items.Add(item);
|
||||
item.SetId = set.Id;
|
||||
db.LearningItems.Add(item); // explizit: Entitäten mit gesetzter Id sind für EF sonst „vorhanden“ (Modified)
|
||||
return await SaveItemsAsync(db, set, notifier, ct);
|
||||
}
|
||||
|
||||
@@ -418,7 +419,7 @@ internal static class LearningManageEndpoints
|
||||
if (answer is null)
|
||||
{
|
||||
answer = new LearningAnswer { AttemptId = attempt.Id, ItemId = itemId };
|
||||
attempt.Answers.Add(answer);
|
||||
db.LearningAnswers.Add(answer);
|
||||
}
|
||||
|
||||
answer.Verdict = request.Verdict;
|
||||
|
||||
@@ -92,6 +92,7 @@ internal static class LearningSolveEndpoints
|
||||
ISettingsProvider settingsProvider,
|
||||
LearningAttemptService service,
|
||||
LearningNotifier notifier,
|
||||
ILogger<LearningAttemptService> logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var actor = await user.LearningActorAsync(settingsProvider);
|
||||
@@ -130,15 +131,16 @@ internal static class LearningSolveEndpoints
|
||||
};
|
||||
created.ItemOrder = LearningRules.SerializeOrder(
|
||||
LearningRules.OrderItems(set.Items.OrderBy(i => i.SortOrder).Select(i => i.Id).ToList(), set.ShuffleItems, created.Id));
|
||||
set.Attempts.Add(created);
|
||||
db.LearningAttempts.Add(created); // explizit: Entitäten mit gesetzter Id sind für EF sonst „vorhanden“ (Modified)
|
||||
LearningAttemptService.RefreshStatus(set);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
logger.LogInformation(ex, "Versuch konnte nicht gespeichert werden, neuer Durchlauf.");
|
||||
// Doppelklick/zweiter Tab: der andere Start hat gewonnen (eindeutiger Index) – beim nächsten Durchlauf wird er fortgesetzt.
|
||||
continue;
|
||||
}
|
||||
@@ -211,7 +213,7 @@ internal static class LearningSolveEndpoints
|
||||
if (answer is null)
|
||||
{
|
||||
answer = new LearningAnswer { AttemptId = entity.Id, ItemId = itemId };
|
||||
entity.Answers.Add(answer);
|
||||
db.LearningAnswers.Add(answer);
|
||||
}
|
||||
|
||||
answer.Value = value;
|
||||
|
||||
@@ -55,6 +55,7 @@ public sealed class DashboardTests(AppFixture fixture)
|
||||
Assert.NotNull(parent.MyTasks);
|
||||
Assert.NotNull(parent.FamilyTasks);
|
||||
Assert.NotNull(parent.Chat);
|
||||
Assert.True(parent.Learning is { ForParents: true });
|
||||
Assert.Equal(FamilyTime.Today(TimeProvider.System), parent.Today);
|
||||
Assert.Equal([parent.Today, parent.Today.AddDays(1)], parent.Events.Select(d => d.Date));
|
||||
|
||||
@@ -64,6 +65,7 @@ public sealed class DashboardTests(AppFixture fixture)
|
||||
Assert.NotNull(kid.MyTasks);
|
||||
Assert.NotNull(kid.Chat);
|
||||
Assert.Null(kid.FamilyTasks);
|
||||
Assert.True(kid.Learning is { ForParents: false });
|
||||
|
||||
var visitor = await GetAsync(guest);
|
||||
Assert.NotNull(visitor.Meals);
|
||||
@@ -73,6 +75,7 @@ public sealed class DashboardTests(AppFixture fixture)
|
||||
Assert.Empty(visitor.MyTasks.Items);
|
||||
Assert.Null(visitor.FamilyTasks);
|
||||
Assert.Null(visitor.Chat);
|
||||
Assert.Null(visitor.Learning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Channels;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using FamilyPlanner.Shared.Tasks;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using static FamilyPlanner.IntegrationTests.LearningSupport;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Punkte-Workflow der Lernblätter (ADR-0021 Punkt 10): Gutschrift erst nach Elternbestätigung, anteilig, beste Wertung zählt einmal,
|
||||
/// Differenz wird gebucht, Rückbuchung beim erneuten Öffnen. Läuft in der Settings-Collection, weil das Punktesystem eine Familieneinstellung ist.
|
||||
/// </summary>
|
||||
[Collection(SettingsCollection.Name)]
|
||||
public sealed class LearningPointsTests(AppFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task<int> BalanceAsync(HttpClient client, Guid userId) =>
|
||||
(await client.GetFromJsonAsync<PointsOverviewDto>("/api/tasks/points", Ct))!.Balances.Single(b => b.UserId == userId).Points;
|
||||
|
||||
/// <summary>Kind löst: Zahl richtig (2 P.), Auswahl richtig (1 P.), Text wie gewünscht – Freitext wird von den Eltern bewertet.</summary>
|
||||
private static async Task<LearningAttemptDto> SolveAsync(HttpClient kid, LearningAttemptDto attempt, bool numberCorrect, bool choiceCorrect, string text)
|
||||
{
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, numberCorrect ? "4711,5" : "1");
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, choiceCorrect ? "0" : "2");
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[2].ItemId, text);
|
||||
return await SubmitAsync(kid, attempt.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Points_are_credited_proportionally_only_after_confirmation_and_the_best_attempt_counts_once()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10, MaxAttempts = 0 });
|
||||
var textItem = set.Items[2].Id;
|
||||
|
||||
// Versuch 1: nur die Zahl richtig, Freitext falsch → 2 von 4 Punkten = 50 % von 10 = 5 Punkte
|
||||
var first = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: true, choiceCorrect: false, text: "Pudel");
|
||||
Assert.Equal(0, await BalanceAsync(admin, child.Id)); // erst nach der Bestätigung
|
||||
await ReviewAsync(parent, first.Id, textItem, LearningVerdict.Wrong);
|
||||
Assert.Equal(0, await BalanceAsync(admin, child.Id));
|
||||
var confirmed = await ConfirmAsync(parent, first.Id);
|
||||
Assert.Equal((5, 5), (confirmed.PointsDelta, confirmed.PointsCredited));
|
||||
Assert.Equal(5, await BalanceAsync(admin, child.Id));
|
||||
Assert.Equal(5, (await GetSetAsync(parent, set.Set.Id)).Set.PointsCredited);
|
||||
Assert.Equal(5, Assert.Single(await MineAsync(kid)).PointsCredited);
|
||||
|
||||
// Audit-Log: Gutschrift mit Blatt und Kind
|
||||
var audit = (await admin.GetFromJsonAsync<FamilyPlanner.Shared.PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct))!;
|
||||
Assert.Contains(audit.Items, e => e.Action == "learning.points_credited" && e.TargetName == set.Set.Title && e.Details!.Contains("+5 Punkte", StringComparison.Ordinal));
|
||||
|
||||
// Bestätigen ist idempotent: keine Doppelbuchung
|
||||
Assert.Equal((0, 5), ((await ConfirmAsync(parent, first.Id)).PointsDelta, (await ConfirmAsync(parent, first.Id)).PointsCredited));
|
||||
Assert.Equal(5, await BalanceAsync(admin, child.Id));
|
||||
|
||||
// Versuch 2 ist besser (3 von 4 = 75 % → 8 Punkte, kaufmännisch gerundet aus 7,5): nur die Differenz (+3) wird gebucht
|
||||
var second = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: true, choiceCorrect: true, text: "Pudel");
|
||||
await ReviewAsync(parent, second.Id, textItem, LearningVerdict.Wrong);
|
||||
var improved = await ConfirmAsync(parent, second.Id);
|
||||
Assert.Equal((3, 8), (improved.PointsDelta, improved.PointsCredited));
|
||||
Assert.Equal(8, await BalanceAsync(admin, child.Id));
|
||||
|
||||
// Versuch 3 ist schlechter: nichts wird gebucht oder abgezogen (beste Wertung zählt)
|
||||
var third = await SolveAsync(kid, await StartAsync(kid, set.Set.Id), numberCorrect: false, choiceCorrect: false, text: "Pudel");
|
||||
await ReviewAsync(parent, third.Id, textItem, LearningVerdict.Wrong);
|
||||
Assert.Equal((0, 8), ((await ConfirmAsync(parent, third.Id)).PointsDelta, 8));
|
||||
Assert.Equal(8, await BalanceAsync(admin, child.Id));
|
||||
|
||||
// Versuch 2 erneut öffnen: Rückbuchung auf die nächstbeste Wertung (Versuch 1: 5 Punkte)
|
||||
var reopened = await ReopenAsync(parent, second.Id);
|
||||
Assert.Equal((-3, 5), (reopened.PointsDelta, reopened.PointsCredited));
|
||||
Assert.Equal(LearningAttemptStatus.Submitted, reopened.Attempt.Status);
|
||||
Assert.Equal(5, await BalanceAsync(admin, child.Id));
|
||||
Assert.Equal(LearningSetStatus.Submitted, (await GetSetAsync(parent, set.Set.Id)).Set.Status); // wartet wieder auf Bestätigung
|
||||
|
||||
// Korrigieren und erneut bestätigen: Freitext war doch richtig → 4 von 4 → 10 Punkte
|
||||
await ReviewAsync(parent, second.Id, textItem, LearningVerdict.Correct);
|
||||
var corrected = await ConfirmAsync(parent, second.Id);
|
||||
Assert.Equal((5, 10), (corrected.PointsDelta, corrected.PointsCredited));
|
||||
Assert.Equal(10, await BalanceAsync(admin, child.Id));
|
||||
Assert.Equal(LearningSetStatus.Reviewed, (await GetSetAsync(parent, set.Set.Id)).Set.Status);
|
||||
|
||||
// Buchungen sind im Punktebuch nachvollziehbar (Kind sieht nur eigene)
|
||||
var overview = (await kid.GetFromJsonAsync<PointsOverviewDto>("/api/tasks/points", Ct))!;
|
||||
Assert.Contains(overview.Recent, e => e.UserId == child.Id && e.Points == -3 && e.Reason.StartsWith("Rückbuchung", StringComparison.Ordinal));
|
||||
Assert.All(overview.Recent, e => Assert.Equal(child.Id, e.UserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sheets_without_points_book_nothing_and_a_partial_review_scores_partially()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
|
||||
// Kein Punktesystem am Blatt: nichts wird gebucht
|
||||
var plain = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 0 });
|
||||
var attempt = await SolveAsync(kid, await StartAsync(kid, plain.Set.Id), true, true, SecretText);
|
||||
var confirmed = await ConfirmAsync(parent, attempt.Id);
|
||||
Assert.Equal((0, 0), (confirmed.PointsDelta, confirmed.PointsCredited));
|
||||
Assert.Equal(0, await BalanceAsync(admin, child.Id));
|
||||
|
||||
// Teilpunkte: Aufgabe mit 4 Punkten, Eltern geben 3 → 3 von 4
|
||||
var items = new List<LearningItemInput> { new("Erkläre den Wasserkreislauf.", LearningAnswerType.Text, "Verdunsten, Kondensieren, Regnen", Points: 4) };
|
||||
var open = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 20, Items = items });
|
||||
var second = await StartAsync(kid, open.Set.Id);
|
||||
await SaveAsync(kid, second.Id, second.Items[0].ItemId, "Wasser verdunstet und regnet ab.");
|
||||
await SubmitAsync(kid, second.Id);
|
||||
var review = await ReviewAsync(parent, second.Id, open.Items[0].Id, LearningVerdict.Partial, 3);
|
||||
Assert.Equal((3, 4, 0), (review.EarnedPoints, review.TotalItemPoints, review.OpenCount));
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/attempts/{second.Id}/answers/{open.Items[0].Id}/review", new ReviewAnswerRequest(LearningVerdict.Partial, 4), Ct)).StatusCode);
|
||||
var result = await ConfirmAsync(parent, second.Id);
|
||||
Assert.Equal((15, 15), (result.PointsDelta, result.PointsCredited)); // 3/4 von 20
|
||||
Assert.Equal(15, await BalanceAsync(admin, child.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_point_system_books_nothing_and_points_changes_are_announced_to_the_family()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
|
||||
|
||||
// Live-Ereignis „Punkte geändert“ beim Bestätigen
|
||||
var points = Channel.CreateUnbounded<bool>();
|
||||
var cookies = new System.Net.CookieContainer();
|
||||
using var login = fixture.CreateApiClient(cookies);
|
||||
await AppFixture.LoginAsync(login, child.UserName, AppFixture.UserPassword, Ct);
|
||||
await using var hub = new HubConnectionBuilder()
|
||||
.WithUrl(new Uri(fixture.WebBaseAddress, "/hubs/live"), options =>
|
||||
{
|
||||
options.Cookies = cookies;
|
||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
|
||||
options.HttpMessageHandlerFactory = _ => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = cookies,
|
||||
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
|
||||
};
|
||||
options.WebSocketConfiguration = ws => ws.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
})
|
||||
.Build();
|
||||
hub.On(TaskEvents.PointsChanged, () => points.Writer.TryWrite(true));
|
||||
await hub.StartAsync(Ct);
|
||||
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10 });
|
||||
var attempt = await SolveAllCorrectlyAsync(kid, await StartAsync(kid, set.Set.Id));
|
||||
await ConfirmAsync(parent, attempt.Id);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
Assert.True(await points.Reader.ReadAsync(timeout.Token));
|
||||
|
||||
// Punktesystem aus: Bestätigen bucht nichts (und ändert nichts an bestehenden Buchungen)
|
||||
try
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, (await admin.PutAsJsonAsync("/api/admin/settings", settings with { ChorePointsEnabled = false }, Ct)).StatusCode);
|
||||
var before = await BalanceAsync(admin, child.Id);
|
||||
var set2 = await CreateAsync(parent, NewSet(child.Id) with { TotalPoints = 10 });
|
||||
var attempt2 = await SolveAllCorrectlyAsync(kid, await StartAsync(kid, set2.Set.Id));
|
||||
var confirmed = await ConfirmAsync(parent, attempt2.Id);
|
||||
Assert.Equal(0, confirmed.PointsDelta);
|
||||
Assert.Equal(before, await BalanceAsync(admin, child.Id));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", settings, Ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>Gemeinsame Hilfen der Lernaufgaben-Integrationstests (Aufrufe über den BFF wie im Browser).</summary>
|
||||
internal static class LearningSupport
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
// Eindeutige Marker, die in Kinder-Antworten nie vorkommen dürfen, solange die Regeln es nicht erlauben.
|
||||
public const string SecretNumber = "4711,5";
|
||||
public const string SecretText = "Dackelwurst";
|
||||
public const string SecretHint = "HINWEIS-GEHEIM-77";
|
||||
public const string SecretExplanation = "ERKLAERUNG-GEHEIM-88";
|
||||
|
||||
/// <summary>Drei Aufgaben: Zahl (2 Punkte), Auswahl (1 Punkt, richtig = Index 0), Text (1 Punkt) – zusammen 4 Aufgabenpunkte.</summary>
|
||||
public static List<LearningItemInput> StandardItems() =>
|
||||
[
|
||||
new("Was ergibt 9423 : 2?", LearningAnswerType.Number, SecretNumber, NumberTolerance: 0.1m, Hint: SecretHint, Explanation: SecretExplanation, Difficulty: 2, Points: 2),
|
||||
new("Hauptstadt von Deutschland?", LearningAnswerType.SingleChoice, "0", Options: ["Berlin", "Paris", "Rom"], Points: 1),
|
||||
new("Nenne einen Hund mit kurzen Beinen.", LearningAnswerType.Text, SecretText, Points: 1),
|
||||
];
|
||||
|
||||
public static CreateLearningSetRequest NewSet(Guid childId, string? title = null) => new(
|
||||
title ?? $"Blatt {Guid.NewGuid():N}"[..14],
|
||||
LearningSubjects.Math,
|
||||
3,
|
||||
childId,
|
||||
MaxAttempts: 0,
|
||||
TotalPoints: 10,
|
||||
Items: StandardItems());
|
||||
|
||||
public static async Task<LearningSetDetailDto> CreateAsync(HttpClient parent, CreateLearningSetRequest request, bool assign = true)
|
||||
{
|
||||
var response = await parent.PostAsJsonAsync("/api/learning/sets", request, Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
|
||||
var detail = (await response.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
|
||||
return assign ? await AssignAsync(parent, detail.Set.Id) : detail;
|
||||
}
|
||||
|
||||
public static async Task<LearningSetDetailDto> AssignAsync(HttpClient parent, Guid setId)
|
||||
{
|
||||
var response = await parent.PostAsync($"/api/learning/sets/{setId}/assign", null, Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningSetDetailDto> GetSetAsync(HttpClient parent, Guid setId) =>
|
||||
(await parent.GetFromJsonAsync<LearningSetDetailDto>($"/api/learning/sets/{setId}", Ct))!;
|
||||
|
||||
public static async Task<List<MyLearningSetDto>> MineAsync(HttpClient kid) =>
|
||||
(await kid.GetFromJsonAsync<List<MyLearningSetDto>>("/api/learning/my", Ct))!;
|
||||
|
||||
public static async Task<LearningAttemptDto> StartAsync(HttpClient kid, Guid setId)
|
||||
{
|
||||
var response = await kid.PostAsync($"/api/learning/my/{setId}/attempts", null, Ct);
|
||||
Assert.True(response.StatusCode is HttpStatusCode.Created or HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningAttemptDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningAttemptDto> GetAttemptAsync(HttpClient kid, Guid attemptId) =>
|
||||
(await kid.GetFromJsonAsync<LearningAttemptDto>($"/api/learning/my/attempts/{attemptId}", Ct))!;
|
||||
|
||||
public static Task<HttpResponseMessage> SaveRawAsync(HttpClient kid, Guid attemptId, Guid itemId, string? value) =>
|
||||
kid.PutAsJsonAsync($"/api/learning/my/attempts/{attemptId}/answers/{itemId}", new SaveAnswerRequest(value), Ct);
|
||||
|
||||
public static async Task<SaveAnswerResultDto> SaveAsync(HttpClient kid, Guid attemptId, Guid itemId, string? value)
|
||||
{
|
||||
var response = await SaveRawAsync(kid, attemptId, itemId, value);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<SaveAnswerResultDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningAttemptDto> SubmitAsync(HttpClient kid, Guid attemptId)
|
||||
{
|
||||
var response = await kid.PostAsync($"/api/learning/my/attempts/{attemptId}/submit", null, Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningAttemptDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningReviewResultDto> ConfirmAsync(HttpClient parent, Guid attemptId)
|
||||
{
|
||||
var response = await parent.PostAsJsonAsync($"/api/learning/attempts/{attemptId}/confirm", new ConfirmAttemptRequest(), Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningReviewResultDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningReviewResultDto> ReopenAsync(HttpClient parent, Guid attemptId)
|
||||
{
|
||||
var response = await parent.PostAsync($"/api/learning/attempts/{attemptId}/reopen", null, Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningReviewResultDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningAttemptReviewDto> ReviewAsync(HttpClient parent, Guid attemptId, Guid itemId, LearningVerdict verdict, int? points = null)
|
||||
{
|
||||
var response = await parent.PutAsJsonAsync(
|
||||
$"/api/learning/attempts/{attemptId}/answers/{itemId}/review", new ReviewAnswerRequest(verdict, points, "Geprüft"), Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<LearningAttemptReviewDto>(Ct))!;
|
||||
}
|
||||
|
||||
public static async Task<LearningAttemptReviewDto> GetReviewAsync(HttpClient parent, Guid attemptId) =>
|
||||
(await parent.GetFromJsonAsync<LearningAttemptReviewDto>($"/api/learning/attempts/{attemptId}", Ct))!;
|
||||
|
||||
public static async Task<List<LearningReviewQueueItemDto>> QueueAsync(HttpClient parent) =>
|
||||
(await parent.GetFromJsonAsync<List<LearningReviewQueueItemDto>>("/api/learning/review", Ct))!;
|
||||
|
||||
/// <summary>Antwortet das Kind „perfekt“ (alle drei Standardaufgaben richtig) und gibt ab.</summary>
|
||||
public static async Task<LearningAttemptDto> SolveAllCorrectlyAsync(HttpClient kid, LearningAttemptDto attempt)
|
||||
{
|
||||
foreach (var item in attempt.Items)
|
||||
{
|
||||
var value = item.AnswerType switch
|
||||
{
|
||||
LearningAnswerType.Number => "4.711,5",
|
||||
LearningAnswerType.SingleChoice => "0",
|
||||
_ => SecretText,
|
||||
};
|
||||
await SaveAsync(kid, attempt.Id, item.ItemId, value);
|
||||
}
|
||||
|
||||
return await SubmitAsync(kid, attempt.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Dashboard;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using static FamilyPlanner.IntegrationTests.LearningSupport;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Lernaufgaben end-to-end über den BFF (ADR-0021): Rechte je Rolle (404 für fremde Kinder), Blätter-CRUD, Auto-Bewertung, Übung vs. Prüfung,
|
||||
/// Lösungen-Leck, Wiederholungen, Bearbeitungssperre, Live-Ereignisse, Dashboard. Punkte: <see cref="LearningPointsTests"/>, Zeitlimit: <see cref="LearningTimeLimitTests"/>.
|
||||
/// </summary>
|
||||
public sealed class LearningTests(AppFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task Permissions_are_enforced_per_role_and_foreign_sheets_are_hidden()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, sibling) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var (_, otherParent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
|
||||
var draft = await CreateAsync(parent, NewSet(child.Id), assign: false);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id));
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
|
||||
// Gast: keinerlei Zugriff
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/learning/sets", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await guest.GetAsync("/api/learning/my", Ct)).StatusCode);
|
||||
|
||||
// Kind: keine Verwaltung, keine Prüfung, keine Bestätigung
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync("/api/learning/sets", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsJsonAsync("/api/learning/sets", NewSet(child.Id), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync("/api/learning/review", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.GetAsync($"/api/learning/attempts/{attempt.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.PostAsJsonAsync($"/api/learning/attempts/{attempt.Id}/confirm", new ConfirmAttemptRequest(), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await kid.DeleteAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
|
||||
|
||||
// Geschwisterkind: alles 404, auch Starten/Speichern/Abgeben; die Liste zeigt nichts
|
||||
Assert.Empty(await MineAsync(sibling));
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.GetAsync($"/api/learning/my/attempts/{attempt.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await SaveRawAsync(sibling, attempt.Id, attempt.Items[0].ItemId, "1")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await sibling.PostAsync($"/api/learning/my/attempts/{attempt.Id}/submit", null, Ct)).StatusCode);
|
||||
|
||||
// Eltern lösen nicht für Kinder: auch für sie ist der fremde Versuch nicht da
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/my/attempts/{attempt.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await parent.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
|
||||
Assert.Empty(await MineAsync(parent));
|
||||
|
||||
// Entwürfe sind für das Zielkind unsichtbar
|
||||
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == draft.Set.Id);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await kid.PostAsync($"/api/learning/my/{draft.Set.Id}/attempts", null, Ct)).StatusCode);
|
||||
|
||||
// Eltern und Admin verwalten alles (auch Blätter anderer Eltern)
|
||||
foreach (var client in new[] { parent, otherParent, admin })
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/api/learning/sets/{set.Set.Id}", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/sets/{Guid.NewGuid()}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/attempts/{Guid.NewGuid()}", Ct)).StatusCode);
|
||||
|
||||
// Der Versuch läuft noch: für Eltern erst nach der Abgabe prüfbar
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await parent.GetAsync($"/api/learning/attempts/{attempt.Id}", Ct)).StatusCode);
|
||||
|
||||
// Ohne Anmeldung: 401
|
||||
using var anonymous = fixture.CreateApiClient();
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/learning/my", Ct)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sheets_are_created_validated_versioned_assigned_and_edited()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (guestUser, _) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
var (parentUser, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
|
||||
// Validierung (serverseitig verbindlich)
|
||||
async Task<HttpStatusCode> PostAsync(CreateLearningSetRequest request) => (await parent.PostAsJsonAsync("/api/learning/sets", request, Ct)).StatusCode;
|
||||
var valid = NewSet(child.Id);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Title = " " }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Subject = "Sport" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { GradeLevel = 7 }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { MaxAttempts = 21 }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TimeLimitMinutes = 0 }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { GradeThresholds = "90;95;70;50;30" }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = parentUser.Id })); // kein Kind
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = guestUser.Id })); // kein Kind
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { TargetUserId = Guid.NewGuid() })); // unbekannt
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Foto machen", LearningAnswerType.Paper, null)] })); // Paper erst in 10c
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Zahl?", LearningAnswerType.Number, "keine Zahl")] }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["nur eine"])] }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Wahl?", LearningAnswerType.SingleChoice, "5", Options: ["a", "b"])] }));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, await PostAsync(valid with { Items = [new("Punkte?", LearningAnswerType.Text, "x", Points: 99)] }));
|
||||
|
||||
// Anlegen als Entwurf (Notenschlüssel normalisiert, Standard → null)
|
||||
var created = await CreateAsync(parent, valid with { Mode = LearningMode.Exam, MaxAttempts = 1, TimeLimitMinutes = 20, GradeThresholds = "92; 81; 67; 50; 30" }, assign: false);
|
||||
Assert.Equal(LearningSetStatus.Draft, created.Set.Status);
|
||||
Assert.Null(created.Set.GradeThresholds);
|
||||
Assert.Equal(LearningSource.Manual, created.Set.Source);
|
||||
Assert.Equal(3, created.Items.Count);
|
||||
Assert.Equal([0, 1, 2], created.Items.Select(i => i.SortOrder));
|
||||
Assert.NotEmpty(created.Set.Version);
|
||||
Assert.Equal(SecretNumber, created.Items[0].CorrectAnswer); // Eltern sehen die Lösung
|
||||
|
||||
// Ändern: Version prüfen (409 mit aktuellem Stand), Ziel muss ein Kind sein
|
||||
var update = UpdateLearningSetRequest.From(created.Set) with { Title = "Neuer Titel", GradeThresholds = "90;80;70;60;50" };
|
||||
var updated = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", update, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
|
||||
var current = (await updated.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
|
||||
Assert.Equal(("Neuer Titel", "90;80;70;60;50"), (current.Set.Title, current.Set.GradeThresholds));
|
||||
var stale = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", update with { Title = "Veraltet" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
|
||||
Assert.Equal("Neuer Titel", (await stale.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Set.Title);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}", UpdateLearningSetRequest.From(current.Set) with { TargetUserId = parentUser.Id }, Ct)).StatusCode);
|
||||
|
||||
// Aufgaben: anhängen, ändern (Version), umsortieren, löschen – das Blatt liefert jeweils den neuen Stand samt Version
|
||||
var added = await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items", new LearningItemInput("Vierte Frage", LearningAnswerType.Text, "Antwort"), Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, added.StatusCode);
|
||||
var withFour = (await added.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
|
||||
Assert.Equal(4, withFour.Items.Count);
|
||||
Assert.Equal(3, withFour.Items[3].SortOrder);
|
||||
Assert.NotEqual(current.Set.Version, withFour.Set.Version);
|
||||
|
||||
var fourth = withFour.Items[3];
|
||||
var edit = UpdateLearningItemRequest.From(fourth) with { Question = "Vierte Frage (neu)" };
|
||||
var edited = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit, Ct);
|
||||
Assert.Equal(HttpStatusCode.OK, edited.StatusCode);
|
||||
var staleItem = await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit with { Question = "Veraltet" }, Ct);
|
||||
Assert.Equal(HttpStatusCode.Conflict, staleItem.StatusCode);
|
||||
Assert.Equal("Vierte Frage (neu)", (await staleItem.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Items[3].Question);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/{fourth.Id}", edit with { AnswerType = LearningAnswerType.Paper, Version = null }, Ct)).StatusCode);
|
||||
|
||||
var ids = withFour.Items.Select(i => i.Id).ToList();
|
||||
var reordered = await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/order", new ReorderLearningItemsRequest([ids[3], ids[0], ids[1], ids[2]]), Ct);
|
||||
Assert.Equal([ids[3], ids[0], ids[1], ids[2]], (await reordered.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Items.Select(i => i.Id));
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PostAsJsonAsync($"/api/learning/sets/{created.Set.Id}/items/order", new ReorderLearningItemsRequest([ids[0]]), Ct)).StatusCode);
|
||||
|
||||
var deleted = await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}/items/{ids[3]}", Ct);
|
||||
var afterDelete = (await deleted.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!;
|
||||
Assert.Equal([ids[0], ids[1], ids[2]], afterDelete.Items.Select(i => i.Id));
|
||||
Assert.Equal([0, 1, 2], afterDelete.Items.Select(i => i.SortOrder));
|
||||
|
||||
// Zuweisen braucht Aufgaben; Entwürfe zurücknehmen geht nur ohne Versuch
|
||||
var empty = await CreateAsync(parent, NewSet(child.Id) with { Items = [] }, assign: false);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{empty.Set.Id}/assign", null, Ct)).StatusCode);
|
||||
var assigned = await AssignAsync(parent, created.Set.Id);
|
||||
Assert.Equal((LearningSetStatus.Assigned, true), (assigned.Set.Status, assigned.Set.AssignedAt is not null));
|
||||
Assert.Contains(await MineAsync(kid), s => s.Id == created.Set.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{created.Set.Id}/assign", null, Ct)).StatusCode); // schon zugewiesen
|
||||
var back = await parent.PostAsync($"/api/learning/sets/{created.Set.Id}/unassign", null, Ct);
|
||||
Assert.Equal(LearningSetStatus.Draft, (await back.Content.ReadFromJsonAsync<LearningSetDetailDto>(Ct))!.Set.Status);
|
||||
Assert.DoesNotContain(await MineAsync(kid), s => s.Id == created.Set.Id);
|
||||
|
||||
// Liste, Löschen (idempotent, Soft Delete)
|
||||
var list = (await parent.GetFromJsonAsync<List<LearningSetDto>>("/api/learning/sets", Ct))!;
|
||||
Assert.Equal(3, list.Single(s => s.Id == created.Set.Id).ItemCount);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await parent.DeleteAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await parent.GetAsync($"/api/learning/sets/{created.Set.Id}", Ct)).StatusCode);
|
||||
Assert.DoesNotContain(await parent.GetFromJsonAsync<List<LearningSetDto>>("/api/learning/sets", Ct) ?? [], s => s.Id == created.Set.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Practice_gives_instant_feedback_grades_automatically_and_sends_free_text_to_the_review()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { ShowSolutionsAfterSubmit = true });
|
||||
|
||||
var mine = Assert.Single(await MineAsync(kid));
|
||||
Assert.Equal((set.Set.Id, 3, true, 0), (mine.Id, mine.ItemCount, mine.CanStart, mine.AttemptsUsed));
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal((LearningAttemptStatus.InProgress, LearningMode.Practice, 1), (attempt.Status, attempt.Mode, attempt.Number));
|
||||
Assert.Null(attempt.DeadlineAt);
|
||||
Assert.Equal(3, attempt.Items.Count);
|
||||
Assert.Equal(SecretHint, attempt.Items[0].Hint); // Übung: Hinweise ja
|
||||
Assert.All(attempt.Items, i => Assert.Null(i.Feedback)); // noch nichts beantwortet
|
||||
|
||||
// Start ist idempotent: ein laufender Versuch wird fortgesetzt (kein zweiter, kein Verbrauch)
|
||||
Assert.Equal(attempt.Id, (await StartAsync(kid, set.Set.Id)).Id);
|
||||
|
||||
var number = attempt.Items[0].ItemId;
|
||||
var choice = attempt.Items[1].ItemId;
|
||||
var text = attempt.Items[2].ItemId;
|
||||
|
||||
// Sofort-Rückmeldung: nur richtig/falsch/offen, nie die Lösung; Zahlenformate mit Tausenderpunkt und Komma
|
||||
var first = await SaveAsync(kid, attempt.Id, number, "4.711,5");
|
||||
Assert.Equal((true, LearningVerdict.Correct), (first.Answered, first.Verdict));
|
||||
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, number, "4711.5")).Verdict);
|
||||
Assert.Equal(LearningVerdict.Wrong, (await SaveAsync(kid, attempt.Id, choice, "1")).Verdict);
|
||||
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, choice, "0")).Verdict);
|
||||
Assert.Equal(LearningVerdict.Correct, (await SaveAsync(kid, attempt.Id, choice, "0")).Verdict); // idempotent
|
||||
Assert.Equal(LearningVerdict.Open, (await SaveAsync(kid, attempt.Id, text, " dackel ")).Verdict); // Freitext: Eltern prüfen
|
||||
var cleared = await SaveAsync(kid, attempt.Id, text, " ");
|
||||
Assert.Equal((false, (LearningVerdict?)null), (cleared.Answered, cleared.Verdict));
|
||||
Assert.Equal(LearningVerdict.Open, (await SaveAsync(kid, attempt.Id, text, "Dackel")).Verdict);
|
||||
|
||||
// Fremde/unbekannte Aufgaben-Id
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await SaveRawAsync(kid, attempt.Id, Guid.NewGuid(), "1")).StatusCode);
|
||||
|
||||
// Während der Bearbeitung nie die Lösung
|
||||
var running = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
|
||||
AssertNoSolutions(running);
|
||||
|
||||
// Abgabe bewertet alles fertig; Lösungen nach Abgabe sichtbar (eingestellt)
|
||||
var submitted = await SubmitAsync(kid, attempt.Id);
|
||||
Assert.Equal(LearningAttemptStatus.Submitted, submitted.Status);
|
||||
Assert.Equal(1, submitted.Result!.OpenCount);
|
||||
Assert.Equal((3, false), (submitted.Result.EarnedPoints, submitted.Result.IsFinal)); // 2 + 1, Freitext offen
|
||||
Assert.Equal(LearningVerdict.Open, submitted.Items[2].Feedback!.Verdict);
|
||||
Assert.Equal(SecretNumber, submitted.Items[0].Feedback!.CorrectAnswer);
|
||||
Assert.Equal(SecretExplanation, submitted.Items[0].Feedback!.Explanation);
|
||||
var again = await SubmitAsync(kid, attempt.Id); // idempotent
|
||||
Assert.Equal((LearningAttemptStatus.Submitted, 3), (again.Status, again.Result!.EarnedPoints));
|
||||
|
||||
// Danach sind Änderungen nicht mehr möglich (409)
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await SaveRawAsync(kid, attempt.Id, number, "1")).StatusCode);
|
||||
|
||||
// Prüf-Warteschlange: Bestätigen geht erst, wenn alles bewertet ist
|
||||
var queued = Assert.Single(await QueueAsync(parent), q => q.AttemptId == attempt.Id);
|
||||
Assert.Equal((1, child.Id, 3, 4), (queued.OpenCount, queued.ChildId, queued.EarnedPoints, queued.TotalItemPoints));
|
||||
var review = await GetReviewAsync(parent, attempt.Id);
|
||||
Assert.Equal(SecretText, review.Answers[2].CorrectAnswer);
|
||||
Assert.Equal("Dackel", review.Answers[2].Answer);
|
||||
Assert.Equal(LearningGrader.Auto, review.Answers[0].Grader);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PostAsJsonAsync($"/api/learning/attempts/{attempt.Id}/confirm", new ConfirmAttemptRequest(), Ct)).StatusCode);
|
||||
|
||||
// Freitext korrigieren: „teilweise“ geht bei 1 Punkt nicht (400), „richtig“ schon
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Partial, 1, null), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Open), Ct)).StatusCode);
|
||||
var reviewed = await ReviewAsync(parent, attempt.Id, text, LearningVerdict.Correct);
|
||||
var parentAnswer = reviewed.Answers[2];
|
||||
Assert.Equal((LearningVerdict.Correct, LearningGrader.Parent, 1, "Geprüft"), (parentAnswer.Verdict, parentAnswer.Grader, parentAnswer.AwardedPoints, parentAnswer.Reason));
|
||||
Assert.Equal((4, 0), (reviewed.EarnedPoints, reviewed.OpenCount));
|
||||
|
||||
var confirmed = await ConfirmAsync(parent, attempt.Id);
|
||||
Assert.Equal(LearningAttemptStatus.Reviewed, confirmed.Attempt.Status);
|
||||
Assert.DoesNotContain(await QueueAsync(parent), x => x.AttemptId == attempt.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/attempts/{attempt.Id}/answers/{text}/review", new ReviewAnswerRequest(LearningVerdict.Wrong), Ct)).StatusCode);
|
||||
|
||||
var final = await GetAttemptAsync(kid, attempt.Id);
|
||||
Assert.Equal((true, 4, 4, 0), (final.Result!.IsFinal, final.Result.EarnedPoints, final.Result.TotalItemPoints, final.Result.OpenCount));
|
||||
Assert.Null(final.Result.Grade); // Übung: keine Note
|
||||
Assert.Equal(LearningSetStatus.Reviewed, Assert.Single(await MineAsync(kid)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exam_hides_feedback_hints_and_solutions_until_submitted_and_grades_with_the_scale()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, ShowSolutionsAfterSubmit = false, MaxAttempts = 1 });
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(LearningMode.Exam, attempt.Mode);
|
||||
Assert.All(attempt.Items, i => Assert.Null(i.Hint));
|
||||
Assert.All(attempt.Items, i => Assert.Null(i.Feedback));
|
||||
|
||||
// Keine Sofort-Rückmeldung: die Antwort des Servers enthält keine Bewertung
|
||||
var number = attempt.Items[0].ItemId;
|
||||
var saved = await SaveAsync(kid, attempt.Id, number, "4711,50");
|
||||
Assert.Equal((true, (LearningVerdict?)null), (saved.Answered, saved.Verdict));
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, "2"); // falsch
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[2].ItemId, " DACKELWURST."); // exakt (normalisiert) → automatisch richtig
|
||||
|
||||
var running = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
|
||||
AssertNoSolutions(running);
|
||||
AssertNoHints(running);
|
||||
AssertNoFeedback(running);
|
||||
|
||||
// Abgegeben: Bewertung sichtbar, aber keine Lösungen/Hinweise (Einstellung „Lösungen zeigen“ aus)
|
||||
var submitted = await SubmitAsync(kid, attempt.Id);
|
||||
Assert.Equal([LearningVerdict.Correct, LearningVerdict.Wrong, LearningVerdict.Correct], submitted.Items.Select(i => i.Feedback!.Verdict));
|
||||
Assert.All(submitted.Items, i => Assert.Null(i.Hint));
|
||||
Assert.All(submitted.Items, i => Assert.Null(i.Feedback!.CorrectAnswer));
|
||||
Assert.All(submitted.Items, i => Assert.Null(i.Feedback!.Explanation));
|
||||
var afterSubmit = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
|
||||
AssertNoSolutions(afterSubmit);
|
||||
AssertNoHints(afterSubmit);
|
||||
Assert.Null(submitted.Result!.Grade); // erst nach der Elternbestätigung
|
||||
Assert.Equal(3, submitted.Result.EarnedPoints);
|
||||
|
||||
// Bestätigen: Note nach Standardschlüssel (3 von 4 = 75 % → Note 3)
|
||||
var parentView = await GetReviewAsync(parent, attempt.Id);
|
||||
Assert.Equal(3, parentView.Grade); // Vorschau, da nichts offen ist
|
||||
var confirmed = await ConfirmAsync(parent, attempt.Id);
|
||||
Assert.Equal(3, confirmed.Attempt.Grade);
|
||||
Assert.Equal(3, (await GetAttemptAsync(kid, attempt.Id)).Result!.Grade);
|
||||
|
||||
// Einmalige Prüfung: kein zweiter Versuch
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
|
||||
var mine = Assert.Single(await MineAsync(kid));
|
||||
Assert.Equal((false, 1), (mine.CanStart, mine.AttemptsUsed));
|
||||
Assert.Equal(3, mine.Results[0].Grade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Solutions_are_shown_after_submission_only_when_enabled_and_never_before()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, ShowSolutionsAfterSubmit = true });
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
var before = await kid.GetStringAsync($"/api/learning/my/attempts/{attempt.Id}", Ct);
|
||||
AssertNoSolutions(before);
|
||||
AssertNoHints(before);
|
||||
AssertNoFeedback(before);
|
||||
foreach (var item in attempt.Items)
|
||||
{
|
||||
await SaveAsync(kid, attempt.Id, item.ItemId, "x");
|
||||
}
|
||||
|
||||
var submitted = await SubmitAsync(kid, attempt.Id);
|
||||
Assert.Equal(SecretNumber, submitted.Items[0].Feedback!.CorrectAnswer);
|
||||
Assert.Equal(SecretExplanation, submitted.Items[0].Feedback!.Explanation);
|
||||
Assert.Equal("0", submitted.Items[1].Feedback!.CorrectAnswer);
|
||||
Assert.Equal(SecretText, submitted.Items[2].Feedback!.CorrectAnswer);
|
||||
Assert.All(submitted.Items, i => Assert.Null(i.Hint)); // Hinweise gibt es nur in der Übung
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attempts_are_limited_and_sets_are_locked_after_the_first_attempt()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { MaxAttempts = 2 });
|
||||
|
||||
var first = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(HttpStatusCode.OK, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode); // läuft → fortgesetzt
|
||||
|
||||
// Nach dem Start: Aufgaben und feste Einstellungen gesperrt, Metadaten änderbar
|
||||
var detail = await GetSetAsync(parent, set.Set.Id);
|
||||
Assert.Equal(LearningSetStatus.InProgress, detail.Set.Status);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PostAsJsonAsync($"/api/learning/sets/{set.Set.Id}/items", new LearningItemInput("Neu", LearningAnswerType.Text, "x"), Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}/items/{detail.Items[0].Id}", UpdateLearningItemRequest.From(detail.Items[0]) with { Points = 5 }, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await parent.DeleteAsync($"/api/learning/sets/{set.Set.Id}/items/{detail.Items[0].Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await parent.PostAsync($"/api/learning/sets/{set.Set.Id}/unassign", null, Ct)).StatusCode);
|
||||
var update = UpdateLearningSetRequest.From(detail.Set);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { Mode = LearningMode.Exam }, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { TotalPoints = 50 }, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK,
|
||||
(await parent.PutAsJsonAsync($"/api/learning/sets/{set.Set.Id}", update with { Title = "Umbenannt", MaxAttempts = 3 }, Ct)).StatusCode);
|
||||
|
||||
// Zwei Versuche waren erlaubt; mit „3“ nach dem Umstellen gibt es einen weiteren
|
||||
await SubmitAsync(kid, first.Id);
|
||||
var second = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(2, second.Number);
|
||||
await SubmitAsync(kid, second.Id);
|
||||
var third = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(3, third.Number);
|
||||
await SubmitAsync(kid, third.Id);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode);
|
||||
var mine = Assert.Single(await MineAsync(kid));
|
||||
Assert.Equal((3, false), (mine.AttemptsUsed, mine.CanStart));
|
||||
Assert.Equal([3, 2, 1], mine.Results.Select(r => r.Number));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shuffled_order_is_stable_per_attempt()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var items = Enumerable.Range(1, 10).Select(i => new LearningItemInput($"Frage {i}", LearningAnswerType.Text, $"A{i}")).ToList();
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { ShuffleItems = true, Items = items, MaxAttempts = 0 });
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
var order = attempt.Items.Select(i => i.ItemId).ToList();
|
||||
Assert.Equal(set.Items.Select(i => i.Id).Order(), order.Order());
|
||||
Assert.Equal(Enumerable.Range(0, 10), attempt.Items.Select(i => i.Position));
|
||||
Assert.Equal(order, (await GetAttemptAsync(kid, attempt.Id)).Items.Select(i => i.ItemId));
|
||||
Assert.Equal(order, (await StartAsync(kid, set.Set.Id)).Items.Select(i => i.ItemId));
|
||||
await SubmitAsync(kid, attempt.Id);
|
||||
Assert.Equal(order, (await GetAttemptAsync(kid, attempt.Id)).Items.Select(i => i.ItemId)); // auch nach der Abgabe
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Live_events_reach_only_the_child_and_parents()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (sibling, _) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (guest, _) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
var (mom, momClient) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var (dad, _) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
|
||||
await using var kidHub = await ListenAsync(child.UserName);
|
||||
await using var siblingHub = await ListenAsync(sibling.UserName);
|
||||
await using var guestHub = await ListenAsync(guest.UserName);
|
||||
await using var momHub = await ListenAsync(mom.UserName);
|
||||
await using var dadHub = await ListenAsync(dad.UserName);
|
||||
|
||||
var set = await CreateAsync(momClient, NewSet(child.Id));
|
||||
var assigned = await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
|
||||
Assert.Equal(LearningChangeKind.Assigned, assigned.Kind);
|
||||
await ReadAsync(momHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
|
||||
await ReadAsync(dadHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Assigned);
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
await SolveAllCorrectlyAsync(kid, attempt);
|
||||
await ReadAsync(momHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
|
||||
await ReadAsync(dadHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
|
||||
await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Submitted);
|
||||
|
||||
await ConfirmAsync(momClient, attempt.Id);
|
||||
await ReadAsync(kidHub.Events.Reader, e => e.SetId == set.Set.Id && e.Kind == LearningChangeKind.Reviewed);
|
||||
|
||||
// Geschwister und Gäste erfahren nichts (die Ereignisse oben waren bereits zugestellt)
|
||||
Assert.False(siblingHub.Events.Reader.TryRead(out _));
|
||||
Assert.False(guestHub.Events.Reader.TryRead(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dashboard_tile_shows_open_sheets_to_children_and_the_review_queue_to_parents()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var (_, guest) = await fixture.CreateSignedInUserAsync(Roles.Guest, Ct);
|
||||
|
||||
Assert.Null((await Dashboard(guest)).Learning);
|
||||
var emptyChild = (await Dashboard(kid)).Learning!;
|
||||
Assert.Equal((0, 0, false), (emptyChild.OpenCount, emptyChild.ToReviewCount, emptyChild.ForParents));
|
||||
Assert.True((await Dashboard(parent)).Learning!.ForParents);
|
||||
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { DueDate = new DateOnly(2000, 1, 1) });
|
||||
var tile = (await Dashboard(kid)).Learning!;
|
||||
Assert.Equal(1, tile.OpenCount);
|
||||
var item = Assert.Single(tile.Items);
|
||||
Assert.Equal((set.Set.Id, set.Set.Title, true), (item.SetId, item.Title, item.IsOverdue));
|
||||
Assert.True((await Dashboard(parent)).Learning!.OpenCount >= 1); // familienweit: andere Tests laufen parallel
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(attempt.Id, Assert.Single((await Dashboard(kid)).Learning!.Items).AttemptId);
|
||||
await SolveAllCorrectlyAsync(kid, attempt);
|
||||
|
||||
Assert.Equal(0, (await Dashboard(kid)).Learning!.OpenCount); // abgegeben = nicht mehr offen
|
||||
var parentTile = (await Dashboard(parent)).Learning!;
|
||||
Assert.True(parentTile.ToReviewCount >= 1);
|
||||
Assert.NotEmpty(parentTile.Items);
|
||||
Assert.All(parentTile.Items, i => Assert.NotNull(i.AttemptId));
|
||||
}
|
||||
|
||||
// ---------- Hilfen ----------
|
||||
|
||||
private static async Task<DashboardDto> Dashboard(HttpClient client) => (await client.GetFromJsonAsync<DashboardDto>("/api/dashboard", Ct))!;
|
||||
|
||||
/// <summary>Keine Musterlösung und Erklärung in der Antwort (Struktur und Marker).</summary>
|
||||
private static void AssertNoSolutions(string json)
|
||||
{
|
||||
Assert.DoesNotContain(SecretExplanation, json);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
var feedback = item.GetProperty("feedback");
|
||||
if (feedback.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Null, feedback.GetProperty("correctAnswer").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, feedback.GetProperty("explanation").ValueKind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertNoHints(string json)
|
||||
{
|
||||
Assert.DoesNotContain(SecretHint, json);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Null, item.GetProperty("hint").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertNoFeedback(string json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
foreach (var item in document.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Null, item.GetProperty("feedback").ValueKind);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Listener> ListenAsync(string userName)
|
||||
{
|
||||
var cookies = new System.Net.CookieContainer();
|
||||
using var login = fixture.CreateApiClient(cookies);
|
||||
await AppFixture.LoginAsync(login, userName, AppFixture.UserPassword, Ct);
|
||||
var listener = new Listener
|
||||
{
|
||||
Connection = new HubConnectionBuilder()
|
||||
.WithUrl(new Uri(fixture.WebBaseAddress, "/hubs/live"), options =>
|
||||
{
|
||||
options.Cookies = cookies;
|
||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
|
||||
options.HttpMessageHandlerFactory = _ => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = cookies,
|
||||
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
|
||||
};
|
||||
options.WebSocketConfiguration = ws => ws.RemoteCertificateValidationCallback = (_, _, _, _) => true;
|
||||
})
|
||||
.Build(),
|
||||
};
|
||||
listener.Connection.On<LearningChangedDto>(LearningEvents.Changed, e => listener.Events.Writer.TryWrite(e));
|
||||
await listener.Connection.StartAsync(Ct);
|
||||
return listener;
|
||||
}
|
||||
|
||||
private sealed class Listener : IAsyncDisposable
|
||||
{
|
||||
public HubConnection Connection { get; set; } = null!;
|
||||
|
||||
public Channel<LearningChangedDto> Events { get; } = Channel.CreateUnbounded<LearningChangedDto>();
|
||||
|
||||
public ValueTask DisposeAsync() => Connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private static async Task<T> ReadAsync<T>(ChannelReader<T> reader, Func<T, bool> predicate)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
while (true)
|
||||
{
|
||||
var value = await reader.ReadAsync(timeout.Token);
|
||||
if (predicate(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Net;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
using static FamilyPlanner.IntegrationTests.LearningSupport;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Serverseitiges Zeitlimit (ADR-0021 Punkt 12). Der Server hält Start und Ablauf fest; Speicherungen nach Ablauf (plus feste Toleranz) werden mit 409
|
||||
/// abgelehnt, der Versuch wird automatisch abgegeben. Die Tests warten echte Zeit ab (kleinstes Limit: 1 Minute) und stehen daher in einer eigenen Klasse,
|
||||
/// die parallel zu den übrigen Lernaufgaben-Tests läuft.
|
||||
/// </summary>
|
||||
public sealed class LearningTimeLimitTests(AppFixture fixture)
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static async Task WaitUntilAsync(DateTimeOffset moment)
|
||||
{
|
||||
var delay = moment - DateTimeOffset.UtcNow;
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(delay, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Late_saves_are_rejected_with_409_and_the_attempt_is_submitted_automatically()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 1 });
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
|
||||
// Start und Ablauf legt der Server fest (genau das Limit), Client-Uhr ist nicht maßgeblich
|
||||
Assert.NotNull(attempt.DeadlineAt);
|
||||
Assert.Equal(TimeSpan.FromMinutes(1), attempt.DeadlineAt!.Value - attempt.StartedAt);
|
||||
Assert.InRange((attempt.ServerNow - DateTimeOffset.UtcNow).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(30));
|
||||
Assert.Equal(attempt.DeadlineAt, (await GetAttemptAsync(kid, attempt.Id)).DeadlineAt); // stabil, kein Zurücksetzen der Uhr
|
||||
Assert.Equal(attempt.DeadlineAt, (await StartAsync(kid, set.Set.Id)).DeadlineAt); // Fortsetzen startet die Uhr nicht neu
|
||||
|
||||
// Innerhalb der Zeit wird gespeichert und bewertet
|
||||
var saved = await SaveAsync(kid, attempt.Id, attempt.Items[0].ItemId, "4711,5");
|
||||
Assert.Equal(attempt.DeadlineAt, saved.DeadlineAt);
|
||||
|
||||
// Nach Ablauf plus Toleranz: 409 – die Antwort zählt nicht
|
||||
await WaitUntilAsync(attempt.DeadlineAt.Value + LearningRules.DeadlineTolerance + TimeSpan.FromSeconds(2));
|
||||
var late = await SaveRawAsync(kid, attempt.Id, attempt.Items[1].ItemId, "0");
|
||||
Assert.Equal(HttpStatusCode.Conflict, late.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await SaveRawAsync(kid, attempt.Id, attempt.Items[0].ItemId, "1")).StatusCode);
|
||||
|
||||
// Automatisch abgegeben (Zeitpunkt = Ablauf), nur bis dahin gespeicherte Antworten sind gewertet
|
||||
var expired = await GetAttemptAsync(kid, attempt.Id);
|
||||
Assert.Equal((LearningAttemptStatus.Submitted, true), (expired.Status, expired.AutoSubmitted));
|
||||
Assert.Equal(attempt.DeadlineAt, expired.Result!.SubmittedAt);
|
||||
Assert.Equal(2, expired.Result.EarnedPoints); // nur die rechtzeitige Zahl (2 Punkte)
|
||||
Assert.Equal(LearningVerdict.Correct, expired.Items[0].Feedback!.Verdict);
|
||||
Assert.Equal(LearningVerdict.Wrong, expired.Items[1].Feedback!.Verdict); // nie beantwortet
|
||||
Assert.Equal("4711,5", expired.Items[0].Answer);
|
||||
Assert.Null(expired.Items[1].Answer);
|
||||
|
||||
// Abgegebener Versuch erscheint in der Prüf-Warteschlange (Eltern); die Abgabe ist idempotent
|
||||
var queued = Assert.Single(await QueueAsync(parent), q => q.AttemptId == attempt.Id);
|
||||
Assert.True(queued.AutoSubmitted);
|
||||
var submitAgain = await SubmitAsync(kid, attempt.Id);
|
||||
Assert.True(submitAgain.AutoSubmitted);
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await kid.PostAsync($"/api/learning/my/{set.Set.Id}/attempts", null, Ct)).StatusCode); // einmalig: verbraucht
|
||||
|
||||
var review = await GetReviewAsync(parent, attempt.Id);
|
||||
Assert.True(review.AutoSubmitted);
|
||||
Assert.Equal(2, review.EarnedPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abandoned_exams_are_finalized_for_the_review_queue_without_any_child_request()
|
||||
{
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
var set = await CreateAsync(parent, NewSet(child.Id) with { Mode = LearningMode.Exam, TimeLimitMinutes = 1, MaxAttempts = 2 });
|
||||
|
||||
var attempt = await StartAsync(kid, set.Set.Id);
|
||||
await SaveAsync(kid, attempt.Id, attempt.Items[1].ItemId, "0");
|
||||
|
||||
// Das Kind schließt die App – der Hintergrunddienst bzw. der Zugriff der Eltern gibt den Versuch nach Ablauf ab.
|
||||
await WaitUntilAsync(attempt.DeadlineAt!.Value + LearningRules.DeadlineTolerance + TimeSpan.FromSeconds(2));
|
||||
var queued = await QueueAsync(parent);
|
||||
Assert.Contains(queued, q => q.AttemptId == attempt.Id && q.AutoSubmitted);
|
||||
|
||||
// Ein neuer Versuch ist erlaubt (2 Versuche), der erste zählt als verbraucht
|
||||
var next = await StartAsync(kid, set.Set.Id);
|
||||
Assert.Equal(2, next.Number);
|
||||
Assert.Equal(LearningAttemptStatus.Submitted, (await GetAttemptAsync(kid, attempt.Id)).Status);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user