test: cover learning UI with bUnit including controllable exam clock

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 14:11:28 +02:00
1 parent bab50cfe90
commit 24a6a1aa66
13 files changed
+2283 -1

No files matched your search

@@ -46,6 +46,18 @@ public sealed class AuthenticationStateProviderTests
var admin = Assert.Single(NavItems.All, i => i.Href == "admin");
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.Admin, admin.Policy);
Assert.All(NavItems.All.Where(i => i.Href != "admin"), i => Assert.Null(i.Policy));
Assert.True(admin.Separated);
Assert.All(NavItems.All.Where(i => i.Href is not ("admin" or "lernen")), i => Assert.Null(i.Policy));
}
[Fact]
public void Learning_navigation_is_for_family_members_only_and_not_in_the_bottom_bar()
{
var learning = Assert.Single(NavItems.All, i => i.Href == "lernen");
Assert.Equal("Lernen", learning.Title);
Assert.Equal(FamilyPlanner.Domain.Authorization.Policies.FamilyMember, learning.Policy); // Admin, Familie, Kind – keine Gäste
Assert.False(learning.InBottomNav);
Assert.False(learning.Separated);
}
}
@@ -281,6 +281,101 @@ public sealed class HomePageTests : BunitContext
Assert.NotNull(cut);
}
// ---------- Kachel „Lernen“ (ADR-0021) ----------
private static DashboardDto WithLearning(DashboardLearningDto? learning) =>
Everything() with { Learning = learning };
[Fact]
public void Kids_see_their_open_sheets_with_deadlines_and_a_link_to_continue()
{
var running = Guid.NewGuid();
_api.Data = WithLearning(new DashboardLearningDto(2, 0, false,
[
new(Guid.NewGuid(), running, "Plus und Minus", "Mathe", Today.AddDays(-1), true, null),
new(Guid.NewGuid(), null, "Diktat", "Deutsch", Today.AddDays(2), false, null),
]));
SignInAs(Roles.Child);
var cut = Render<DashboardHost>();
Assert.Contains("Lernen", cut.FindAll(".fp-tile h2").Select(h => h.TextContent.Trim()));
var tile = cut.Find(".fp-dash-learning");
Assert.Contains("2 offene Übungsblätter", tile.QuerySelector(".fp-dash-learning-summary")!.TextContent);
var items = tile.QuerySelectorAll(".fp-dash-learning-list li");
Assert.Equal(2, items.Length);
Assert.Equal($"lernen/versuch/{running}", items[0].QuerySelector("a")!.GetAttribute("href")); // laufender Versuch → direkt weitermachen
Assert.Contains("Mathe · überfällig seit 11.10.", items[0].TextContent);
Assert.Contains("überfällig", items[0].QuerySelector(".fp-dash-overdue")!.TextContent);
Assert.Equal("lernen", items[1].QuerySelector("a")!.GetAttribute("href"));
Assert.Contains("Deutsch · fällig Mi., 14.10.", items[1].TextContent);
Assert.Empty(items[1].QuerySelectorAll(".fp-dash-overdue"));
Assert.Equal("lernen", tile.QuerySelector(".mud-card-actions a")!.GetAttribute("href"));
}
[Fact]
public void Parents_see_submissions_to_review_and_open_sheets_of_the_children()
{
var attempt = Guid.NewGuid();
_api.Data = WithLearning(new DashboardLearningDto(3, 2, true, [new(Guid.NewGuid(), attempt, "Mathe-Test", "Mathe", null, false, "Mia")]));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
var tile = cut.Find(".fp-dash-learning");
Assert.Contains("2 Abgaben zu prüfen", tile.QuerySelector(".fp-dash-learning-summary")!.TextContent);
Assert.Contains("3 Blätter offen", tile.QuerySelector(".fp-dash-learning-open")!.TextContent);
var link = tile.QuerySelector(".fp-dash-learning-list a")!;
Assert.Equal($"lernen/pruefen/{attempt}", link.GetAttribute("href"));
Assert.Contains("Mia: Mathe-Test", link.TextContent);
}
[Fact]
public void The_learning_tile_has_friendly_empty_states_and_is_absent_without_the_right()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, true, []));
SignInAs(Roles.Admin);
var parents = Render<DashboardHost>();
Assert.Contains("Nichts zu prüfen.", parents.Find(".fp-dash-learning-summary").TextContent);
Assert.Empty(parents.FindAll(".fp-dash-learning-list"));
Assert.Empty(parents.FindAll(".fp-dash-learning-open"));
}
[Fact]
public void Children_without_open_sheets_are_praised_and_guests_get_no_tile()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, false, []));
SignInAs(Roles.Child);
var kid = Render<DashboardHost>();
Assert.Contains("Keine offenen Übungsblätter – super!", kid.Find(".fp-dash-learning-summary").TextContent);
}
[Fact]
public void Dashboards_without_a_learning_tile_show_no_learning_card()
{
_api.Data = WithLearning(null);
SignInAs(Roles.Guest);
var cut = Render<DashboardHost>();
Assert.Empty(cut.FindAll(".fp-dash-learning"));
}
[Fact]
public void Live_learning_events_reload_the_dashboard()
{
_api.Data = WithLearning(new DashboardLearningDto(0, 0, true, []));
SignInAs(Roles.Family);
var cut = Render<DashboardHost>();
var before = _api.Calls;
_api.Data = WithLearning(new DashboardLearningDto(1, 1, true, [new(Guid.NewGuid(), Guid.NewGuid(), "Neu abgegeben", "Mathe", null, false, "Ben")]));
cut.InvokeAsync(() => _live.RaiseLearningChanged(new FamilyPlanner.Shared.Learning.LearningChangedDto(Guid.NewGuid(), FamilyPlanner.Shared.Learning.LearningChangeKind.Submitted)));
cut.WaitForAssertion(() => Assert.True(_api.Calls > before));
cut.WaitForAssertion(() => Assert.Contains("1 Abgabe zu prüfen", cut.Find(".fp-dash-learning-summary").TextContent));
}
private static TaskDto NewTask(string title) => new(Guid.NewGuid(), title, null, null, null, null, null, TodoPriority.Normal, TodoStatus.Open, null,
false, false, 0, false, false, [], null, default, null, [], null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, []);
@@ -0,0 +1,216 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Prüfung durch Eltern: Freitext bewerten, Automatik korrigieren, Teilpunkte und Begründung, Bestätigen, erneut öffnen.</summary>
public sealed class AttemptReviewTests : LearningTestBase
{
private static LearningReviewAnswerDto Answer(int position, string question, LearningVerdict verdict, int points = 2, string? given = "Antwort", string? correct = "Lösung",
LearningGrader grader = LearningGrader.Auto, int awarded = 0, string? reason = null, LearningAnswerType type = LearningAnswerType.Text, IReadOnlyList<string>? options = null) =>
new(Guid.NewGuid(), position, question, type, options ?? [], correct, null, points, given, verdict, grader, awarded, reason);
private LearningAttemptReviewDto Review(
LearningAttemptStatus status = LearningAttemptStatus.Submitted,
LearningMode mode = LearningMode.Practice,
bool auto = false,
int? grade = null,
params LearningReviewAnswerDto[] answers)
{
var rows = answers.Length > 0
? answers
:
[
Answer(0, "Was ist 9 + 3?", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number),
Answer(1, "Nenne ein Haustier.", LearningVerdict.Open, 3, "Dackel", "Hund"),
];
var score = LearningRules.Score(rows.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
var review = new LearningAttemptReviewDto(
Guid.NewGuid(), Guid.NewGuid(), "Mathe-Test", LearningSubjects.Math, mode, 10, FakeLearningApi.Mia, "Mia", 2, status,
FakeLearningApi.Now.AddMinutes(-30), FakeLearningApi.Now.AddMinutes(-5), auto, rows, score.Earned, rows.Sum(r => r.ItemPoints), score.OpenCount, grade, 0, null, [1]);
Api.Reviews[review.AttemptId] = review;
return review;
}
private IRenderedComponent<LearningHost> Open(LearningAttemptReviewDto review)
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<AttemptReview>(c => c.Add(x => x.AttemptId, review.AttemptId)));
}
[Fact]
public void Summary_and_answers_show_child_answer_solution_verdict_and_who_graded()
{
var review = Review(answers:
[
Answer(0, "Was ist 9 + 3?", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number),
Answer(1, "Hauptstadt?", LearningVerdict.Wrong, 1, "0", "1", type: LearningAnswerType.SingleChoice, options: ["Paris", "Berlin"]),
Answer(2, "Aufsatz", LearningVerdict.Partial, 4, "Mein Hund", "Siehe Heft", LearningGrader.Parent, 3, "Satzbau"),
Answer(3, "Nenne ein Haustier.", LearningVerdict.Open, 1, "Dackel", "Hund"),
]);
var cut = Open(review);
Assert.Equal("Mia – Mathe-Test", cut.Find(".fp-review-summary h2").TextContent);
Assert.Contains("Versuch 2", cut.Find(".fp-review-summary").TextContent);
Assert.Contains("abgegeben 12.10.2026 09:55", cut.Find(".fp-review-summary").TextContent);
Assert.Equal("5 Punkte von 8", Text(cut.Find(".fp-review-score")));
Assert.Contains("1 Antwort offen", cut.Find(".fp-review-open").TextContent);
Assert.Empty(cut.FindAll(".fp-review-confirmed"));
var rows = cut.FindAll(".fp-review-answer");
Assert.Equal(4, rows.Count);
Assert.Equal(["Richtig", "Falsch", "Teilweise richtig (3 von 4)", "Wird geprüft"], rows.Select(r => Text(r.QuerySelector(".fp-review-verdict")!)).ToList());
Assert.Contains("Antwort: Paris", Text(rows[1].QuerySelector(".fp-review-given")!)); // Index → Optionstext
Assert.Contains("Musterlösung: Berlin", Text(rows[1].QuerySelector(".fp-review-correct")!));
Assert.Contains("automatisch bewertet", rows[0].QuerySelector(".fp-review-grader")!.TextContent);
Assert.Contains("von den Eltern bewertet: Satzbau", rows[2].QuerySelector(".fp-review-grader")!.TextContent);
}
[Fact]
public void Confirm_is_disabled_until_every_open_answer_is_rated()
{
var review = Review();
var cut = Open(review);
Assert.True(cut.Find(".fp-review-confirm").HasAttribute("disabled"));
Assert.Contains("Bitte zuerst alle offenen Antworten bewerten.", cut.Find(".fp-review-hint").TextContent);
cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-correct-button")!.Click();
cut.WaitForAssertion(() => Assert.False(cut.Find(".fp-review-confirm").HasAttribute("disabled")));
Assert.Empty(cut.FindAll(".fp-review-hint"));
var (attempt, item, request) = Assert.Single(Api.Reviewed);
Assert.Equal((review.AttemptId, review.Answers[1].ItemId, LearningVerdict.Correct, (int?)null), (attempt, item, request.Verdict, request.AwardedPoints));
Assert.Equal("5 Punkte von 5", Text(cut.Find(".fp-review-score")));
Assert.Contains("von den Eltern bewertet", cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-grader")!.TextContent);
Assert.Equal("true", cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-correct-button")!.GetAttribute("aria-pressed"));
}
[Fact]
public void Partial_points_and_a_reason_are_sent_with_the_verdict()
{
var review = Review();
var cut = Open(review);
AngleSharp.Dom.IElement Row() => cut.FindAll(".fp-review-answer")[1]; // nach jeder Eingabe neu suchen (die Ansicht wird neu gerendert)
Assert.Equal("1", Row().QuerySelector(".fp-review-points input")!.GetAttribute("value")); // Standard: Hälfte, mindestens 1 (3 Punkte → 1)
Row().QuerySelector(".fp-review-points input")!.Change("2");
Row().QuerySelector(".fp-review-reason input")!.Change("Dackel ist ein Hund, aber andere Schreibweise");
Row().QuerySelector(".fp-review-partial-button")!.Click();
cut.WaitForAssertion(() => Assert.Single(Api.Reviewed));
var request = Api.Reviewed[0].Request;
Assert.Equal((LearningVerdict.Partial, 2, "Dackel ist ein Hund, aber andere Schreibweise"), (request.Verdict, request.AwardedPoints, request.Reason));
cut.WaitForAssertion(() => Assert.Contains("Teilweise richtig (2 von 3)", cut.FindAll(".fp-review-verdict")[1].TextContent));
}
[Fact]
public void One_point_items_offer_no_partial_credit()
{
var review = Review(answers: [Answer(0, "Einpunkter", LearningVerdict.Open, 1)]);
var cut = Open(review);
Assert.Empty(cut.FindAll(".fp-review-partial-button"));
Assert.Empty(cut.FindAll(".fp-review-points"));
Assert.NotEmpty(cut.FindAll(".fp-review-wrong-button"));
}
[Fact]
public void Automatic_verdicts_can_be_corrected()
{
var review = Review(answers: [Answer(0, "Zahl", LearningVerdict.Wrong, 2, "12,5", "12,50", type: LearningAnswerType.Number)]);
var cut = Open(review);
cut.Find(".fp-review-correct-button").Click();
cut.WaitForAssertion(() => Assert.Equal("Richtig", Text(cut.Find(".fp-review-verdict"))));
Assert.Equal("2 Punkte von 2", Text(cut.Find(".fp-review-score")));
}
[Fact]
public void Confirming_sends_the_note_and_shows_the_credited_points_with_a_reopen_option()
{
var review = Review(answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]);
var cut = Open(review);
Assert.Empty(cut.FindAll(".fp-review-hint"));
cut.Find(".fp-review-note textarea").Change("Super gemacht!");
cut.Find(".fp-review-confirm").Click();
cut.WaitForAssertion(() => Assert.Single(Api.Confirmed));
Assert.Equal((review.AttemptId, "Super gemacht!"), (Api.Confirmed[0].Attempt, Api.Confirmed[0].Request.Note));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirmed")));
Assert.Contains("insgesamt 7 Punkte für dieses Blatt gutgeschrieben", cut.Find(".fp-review-credited").TextContent);
Assert.Empty(cut.FindAll(".fp-review-actions")); // bestätigte Versuche sind schreibgeschützt
Assert.Empty(cut.FindAll(".fp-review-confirm"));
Assert.Contains("Bestätigt – 7 Punkte gutgeschrieben.", cut.Markup); // Hinweis (Snackbar)
cut.Find(".fp-review-reopen").Click();
cut.WaitForAssertion(() => Assert.Equal([review.AttemptId], Api.Reopened));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirm")));
Assert.Empty(cut.FindAll(".fp-review-confirmed"));
Assert.Contains("7 Punkte zurückgebucht", cut.Markup);
}
[Fact]
public void Exam_shows_a_grade_preview_and_the_final_grade_after_confirmation()
{
var done = Review(LearningAttemptStatus.Submitted, LearningMode.Exam, grade: 2,
answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]);
var cut = Open(done);
Assert.Equal("Vorschau: Note 2 – gut", Text(cut.Find(".fp-review-grade")));
cut.Find(".fp-review-confirm").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-review-confirmed")));
}
[Fact]
public void Reviewed_attempts_show_the_final_grade_without_preview_label()
{
var review = Review(LearningAttemptStatus.Reviewed, LearningMode.Exam, grade: 3,
answers: [Answer(0, "Zahl", LearningVerdict.Correct, 2, "12", "12", awarded: 2, type: LearningAnswerType.Number)]) with { PointsCredited = 5 };
Api.Reviews[review.AttemptId] = review;
var cut = Open(review);
Assert.Equal("Note 3 – befriedigend", Text(cut.Find(".fp-review-grade")));
Assert.NotEmpty(cut.FindAll(".fp-review-confirmed"));
Assert.Contains("5 Punkte", cut.Find(".fp-review-credited").TextContent);
}
[Fact]
public void Auto_submitted_attempts_are_explained()
{
var cut = Open(Review(auto: true));
Assert.Contains("automatisch abgegeben", cut.Find(".fp-review-auto").TextContent);
Assert.Contains("Nur rechtzeitig gespeicherte Antworten zählen.", cut.Find(".fp-review-auto").TextContent);
}
[Fact]
public void Missing_attempts_and_running_attempts_are_reported()
{
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptReview>(c => c.Add(x => x.AttemptId, Guid.NewGuid())));
Assert.Contains("Diesen Versuch gibt es nicht (mehr).", cut.Find(".fp-learning-missing").TextContent);
}
[Fact]
public void Rejected_ratings_show_the_server_message()
{
var review = Review();
var cut = Open(review);
Api.Offline = true;
cut.FindAll(".fp-review-answer")[1].QuerySelector(".fp-review-wrong-button")!.Click();
cut.WaitForAssertion(() => Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-dialog-error").TextContent));
}
}
@@ -0,0 +1,486 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Lösen eines Blatts (Übung und Prüfung): Fortschritt, Speichern, Rückmeldung, Hinweise, Abgabe-Bestätigung, Countdown nach Serveruhr, Auto-Abgabe.</summary>
public sealed class AttemptRunnerTests : LearningTestBase
{
private static readonly DateTimeOffset Start = FakeLearningApi.Now;
private static LearningAttemptDto Attempt(LearningMode mode = LearningMode.Practice, DateTimeOffset? deadline = null, params LearningAttemptItemDto[] items)
{
var rows = items.Length > 0
? items
:
[
FakeLearningApi.AttemptItem(0, "Was ist 9 + 3?", LearningAnswerType.Number, 2),
FakeLearningApi.AttemptItem(1, "Nenne ein Haustier.", LearningAnswerType.Text),
FakeLearningApi.AttemptItem(2, "Hauptstadt von Deutschland?", LearningAnswerType.SingleChoice, 1, ["Paris", "Berlin", "Rom"]),
];
return FakeLearningApi.NewAttempt("Rechnen", mode, rows, deadline);
}
private IRenderedComponent<LearningHost> Open(LearningAttemptDto attempt)
{
Api.Attempts[attempt.Id] = attempt;
SignInAs(Roles.Child);
return Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, attempt.Id)));
}
private static string Progress(IRenderedComponent<LearningHost> cut) => cut.Find(".fp-learning-progress-text").TextContent;
// ---------- Übung ----------
[Fact]
public void Shows_the_first_question_with_progress_navigation_and_no_clock_without_a_time_limit()
{
var cut = Open(Attempt());
Assert.Equal("Rechnen", cut.Find(".fp-learning-run-title").TextContent);
Assert.Contains("Übung", cut.Find(".fp-learning-mode").TextContent);
Assert.Equal("0 von 3 beantwortet", Progress(cut));
Assert.Equal("Was ist 9 + 3?", cut.Find(".fp-learning-question").TextContent);
Assert.Contains("Aufgabe 1 von 3", cut.Find(".fp-learning-question-card").TextContent);
Assert.Contains("2 Punkte", cut.Find(".fp-learning-question-card").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-timer"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
Assert.True(cut.Find(".fp-learning-prev").HasAttribute("disabled"));
var dots = cut.FindAll(".fp-learning-dot");
Assert.Equal(3, dots.Count);
Assert.Equal("step", dots[0].GetAttribute("aria-current"));
Assert.Equal("Aufgabe 1, offen", dots[0].GetAttribute("aria-label"));
Assert.Contains("fp-learning-submit", cut.Find(".fp-learning-submit").ClassList);
}
[Fact]
public void Number_answers_are_saved_on_change_with_instant_feedback_in_practice()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12,5");
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal([(attempt.Id, attempt.Items[0].ItemId, "12,5")], Api.Saved);
Assert.Contains("Richtig! 🎉", cut.Find(".fp-learning-feedback").TextContent);
Assert.Equal("1 von 3 beantwortet", Progress(cut));
Assert.Equal("Aufgabe 1, beantwortet", cut.FindAll(".fp-learning-dot")[0].GetAttribute("aria-label"));
Assert.Equal("12,5", cut.Find(".fp-learning-number input").GetAttribute("value"));
}
[Fact]
public void Wrong_and_open_verdicts_get_gentle_messages_and_cleared_answers_remove_the_feedback()
{
var attempt = Attempt();
var cut = Open(attempt);
Api.SaveBehavior = (_, _, value) => ApiResult<SaveAnswerResultDto>.Ok(
new SaveAnswerResultDto(attempt.Items[0].ItemId, value is not null, value is null ? null : value == "x" ? LearningVerdict.Open : LearningVerdict.Wrong, null, Start));
cut.Find(".fp-learning-number input").Change("1");
cut.WaitForAssertion(() => Assert.Contains("Nicht ganz – versuch es noch einmal.", cut.Find(".fp-learning-feedback").TextContent));
cut.Find(".fp-learning-number input").Change("x");
cut.WaitForAssertion(() => Assert.Contains("Das schauen sich Mama oder Papa an.", cut.Find(".fp-learning-feedback").TextContent));
cut.Find(".fp-learning-number input").Change(string.Empty);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-learning-feedback")));
Assert.Equal("0 von 3 beantwortet", Progress(cut));
Assert.Equal(["1", "x", null], Api.Saved.Select(s => s.Value));
}
[Fact]
public void Unchanged_values_are_not_saved_again()
{
var cut = Open(Attempt());
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Single(Api.Saved));
cut.Find(".fp-learning-number input").Change(" 12 "); // nur Leerraum geändert
cut.Find(".fp-learning-next").Click();
cut.Find(".fp-learning-prev").Click();
Assert.Single(Api.Saved);
}
[Fact]
public void Hints_are_hidden_until_requested_and_only_shown_when_the_server_sent_them()
{
var attempt = Attempt(items:
[
FakeLearningApi.AttemptItem(0, "Mit Hinweis", hint: "Rechne zuerst die Klammer."),
FakeLearningApi.AttemptItem(1, "Ohne Hinweis"),
]);
var cut = Open(attempt);
Assert.Empty(cut.FindAll(".fp-learning-hint"));
Assert.DoesNotContain("Rechne zuerst die Klammer.", cut.Markup);
cut.Find(".fp-learning-hint-button").Click();
Assert.Contains("Rechne zuerst die Klammer.", cut.Find(".fp-learning-hint").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
cut.Find(".fp-learning-next").Click();
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
Assert.Empty(cut.FindAll(".fp-learning-hint"));
}
[Fact]
public void Choice_answers_are_selected_with_one_tap_and_saved_as_the_option_index()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.FindAll(".fp-learning-dot")[2].Click();
var options = cut.FindAll("button[role='radio']");
Assert.Equal(["AParis", "BBerlin", "CRom"], options.Select(o => string.Concat(Text(o).Where(c => !char.IsWhiteSpace(c)))).ToList());
Assert.All(options, o => Assert.Equal("false", o.GetAttribute("aria-checked")));
options[1].Click();
cut.WaitForAssertion(() => Assert.Equal("true", cut.FindAll("button[role='radio']")[1].GetAttribute("aria-checked")));
Assert.Equal((attempt.Items[2].ItemId, "1"), (Api.Saved.Last().Item, Api.Saved.Last().Value));
Assert.Equal("1 von 3 beantwortet", Progress(cut));
Assert.Equal("radiogroup", cut.Find(".fp-learning-choices").GetAttribute("role"));
}
[Fact]
public void Navigation_moves_between_questions_and_keeps_the_entered_values()
{
var cut = Open(Attempt());
cut.Find(".fp-learning-number input").Change("12");
cut.Find(".fp-learning-next").Click();
Assert.Contains("Aufgabe 2 von 3", cut.Find(".fp-learning-question-card").TextContent);
Assert.Equal("Nenne ein Haustier.", cut.Find(".fp-learning-question").TextContent);
Assert.False(cut.Find(".fp-learning-prev").HasAttribute("disabled"));
cut.Find(".fp-learning-text textarea").Change("Hund");
cut.FindAll(".fp-learning-dot")[2].Click(); // Sprung zur letzten Aufgabe
Assert.Empty(cut.FindAll(".fp-learning-next")); // letzte Aufgabe: kein „Weiter“
Assert.Equal("step", cut.FindAll(".fp-learning-dot")[2].GetAttribute("aria-current"));
cut.Find(".fp-learning-prev").Click();
Assert.Equal("Hund", cut.Find(".fp-learning-text textarea").TextContent.Trim());
cut.Find(".fp-learning-prev").Click();
Assert.Equal("12", cut.Find(".fp-learning-number input").GetAttribute("value"));
Assert.Equal("2 von 3 beantwortet", Progress(cut));
}
[Fact]
public void Existing_answers_of_a_resumed_attempt_are_restored()
{
var attempt = Attempt(items:
[
FakeLearningApi.AttemptItem(0, "A", answer: "42"),
FakeLearningApi.AttemptItem(1, "B"),
]);
var cut = Open(attempt);
Assert.Equal("42", cut.Find(".fp-learning-number input").GetAttribute("value"));
Assert.Equal("1 von 2 beantwortet", Progress(cut));
}
[Fact]
public void Saves_run_one_after_another_and_the_latest_value_wins()
{
var attempt = Attempt();
var cut = Open(attempt);
var gate = new TaskCompletionSource();
Api.SaveGate = gate;
cut.Find(".fp-learning-number input").Change("1");
cut.Find(".fp-learning-number input").Change("12");
// Die zweite Speicherung wartet auf die erste (keine überholende Anfrage)
Assert.Equal(["1"], Api.Saved.Select(s => s.Value));
gate.SetResult();
cut.WaitForAssertion(() => Assert.Equal(["1", "12"], Api.Saved.Select(s => s.Value)));
cut.WaitForAssertion(() => Assert.Equal("12", Api.Attempts[attempt.Id].Items[0].Answer));
}
[Fact]
public void Failed_saves_are_marked_and_can_be_retried()
{
var attempt = Attempt();
var cut = Open(attempt);
Api.Offline = true;
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Nicht gespeichert", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal("1 von 3 beantwortet", Progress(cut)); // lokal beantwortet, aber noch nicht gespeichert
Api.Offline = false;
cut.Find(".fp-learning-save-failed button").Click();
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Equal(["12", "12"], Api.Saved.Select(s => s.Value));
}
// ---------- Abgabe ----------
[Fact]
public void Submitting_asks_for_confirmation_mentions_open_questions_and_shows_the_result()
{
var attempt = Attempt();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12");
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Wirklich abgeben?", cut.Markup));
Assert.Contains("2 Aufgaben sind noch ohne Antwort.", cut.Markup);
Assert.Empty(Api.Submitted);
cut.FindAll("button").First(b => Text(b) == "Weiterarbeiten").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Wirklich abgeben?", cut.Markup));
Assert.Empty(Api.Submitted);
var again = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Wirklich abgeben?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Ja, abgeben").Click();
cut.WaitForAssertion(() => Assert.Equal([attempt.Id], Api.Submitted));
cut.WaitForAssertion(() => Assert.Contains("Abgegeben – gut gemacht!", cut.Markup));
Assert.Equal("2 Punkte von 4", Text(cut.Find(".fp-learning-score")));
Assert.Empty(cut.FindAll(".fp-learning-runner .fp-learning-question-card"));
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(again.IsCompleted || !again.IsFaulted);
}
[Fact]
public void Submitting_with_everything_answered_does_not_warn_about_open_questions()
{
var attempt = Attempt(items: [FakeLearningApi.AttemptItem(0, "Nur eine Frage")]);
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("1");
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-submit").Click());
cut.WaitForAssertion(() => Assert.Contains("Du hast alle Aufgaben beantwortet.", cut.Markup));
Assert.DoesNotContain("noch ohne Antwort", cut.Markup);
Assert.True(click.IsCompleted || !click.IsFaulted);
}
[Fact]
public void A_submitted_attempt_opened_directly_shows_the_result_with_verdicts_and_only_allowed_solutions()
{
var items = new[]
{
FakeLearningApi.AttemptItem(0, "Was ist 9 + 3?", points: 2, answer: "11",
feedback: new LearningItemFeedbackDto(LearningVerdict.Wrong, 0, null, "12", "Neun plus drei ergibt zwölf.")),
FakeLearningApi.AttemptItem(1, "Hauptstadt?", LearningAnswerType.SingleChoice, 1, ["Paris", "Berlin"], answer: "1",
feedback: new LearningItemFeedbackDto(LearningVerdict.Correct, 1, null, null, null)),
FakeLearningApi.AttemptItem(2, "Aufsatz", LearningAnswerType.Text, 3, answer: "Mein Hund",
feedback: new LearningItemFeedbackDto(LearningVerdict.Partial, 2, "Satzbau beachten", null, null)),
FakeLearningApi.AttemptItem(3, "Offen", LearningAnswerType.Text, 1, answer: "?",
feedback: new LearningItemFeedbackDto(LearningVerdict.Open, 0, null, null, null)),
FakeLearningApi.AttemptItem(4, "Nicht beantwortet", answer: null,
feedback: new LearningItemFeedbackDto(LearningVerdict.Wrong, 0, null, "7", null)),
};
var attempt = Attempt(items: items) with
{
Status = LearningAttemptStatus.Submitted,
Result = new MyLearningResultDto(Guid.NewGuid(), 1, LearningAttemptStatus.Submitted, Start, false, 3, 8, 1, null, false),
};
var cut = Open(attempt);
Assert.Equal("3 Punkte von 8", Text(cut.Find(".fp-learning-score")));
Assert.Contains("1 Antwort wird noch von Mama oder Papa angeschaut", cut.Find(".fp-learning-pending").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-grade"));
var rows = cut.FindAll(".fp-learning-answer");
Assert.Equal(5, rows.Count);
Assert.Equal(["Falsch", "Richtig", "Teilweise richtig (2 von 3)", "Wird geprüft", "Falsch"], rows.Select(r => Text(r.QuerySelector(".fp-learning-verdict")!)).ToList());
Assert.Contains("Deine Antwort: 11", Text(rows[0].QuerySelector(".fp-learning-given")!));
Assert.Contains("Richtige Lösung: 12", Text(rows[0].QuerySelector(".fp-learning-correct")!));
Assert.Contains("Neun plus drei ergibt zwölf.", rows[0].QuerySelector(".fp-learning-explanation")!.TextContent);
Assert.Contains("Deine Antwort: Berlin", Text(rows[1].QuerySelector(".fp-learning-given")!)); // Index 1 → Text der Option
Assert.Null(rows[1].QuerySelector(".fp-learning-correct")); // Lösung nicht freigegeben → nichts anzeigen
Assert.Contains("Kommentar: Satzbau beachten", rows[2].QuerySelector(".fp-learning-reason")!.TextContent);
Assert.Contains("Deine Antwort: keine Antwort", Text(rows[4].QuerySelector(".fp-learning-given")!));
Assert.Contains("Richtige Lösung: 7", Text(rows[4].QuerySelector(".fp-learning-correct")!));
Assert.Empty(Api.Saved);
Assert.Equal("lernen", cut.Find(".fp-learning-back").GetAttribute("href"));
}
[Fact]
public void A_confirmed_exam_result_shows_the_grade()
{
var attempt = Attempt(LearningMode.Exam, items: [FakeLearningApi.AttemptItem(0, "Frage", feedback: new LearningItemFeedbackDto(LearningVerdict.Correct, 1, null, null, null))]) with
{
Status = LearningAttemptStatus.Reviewed,
Result = new MyLearningResultDto(Guid.NewGuid(), 1, LearningAttemptStatus.Reviewed, Start, false, 1, 1, 0, 1, true),
};
var cut = Open(attempt);
Assert.Equal("Note 1 – sehr gut", Text(cut.Find(".fp-learning-grade")));
Assert.Contains("Von Mama oder Papa bestätigt", cut.Find(".fp-learning-final").TextContent);
}
[Fact]
public void Unknown_attempts_are_reported_with_a_way_back()
{
SignInAs(Roles.Child);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, Guid.NewGuid())));
Assert.Contains("Dieses Blatt gibt es nicht oder du hast keinen Zugriff.", cut.Find(".fp-learning-missing").TextContent);
}
[Fact]
public void Load_errors_offer_a_retry()
{
var attempt = Attempt();
Api.Attempts[attempt.Id] = attempt;
Api.Offline = true;
SignInAs(Roles.Child);
var cut = Render<LearningHost>(p => p.AddChildContent<AttemptRunner>(c => c.Add(x => x.AttemptId, attempt.Id)));
Assert.Contains("Keine Verbindung", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
cut.Find(".fp-learning-error button").Click();
cut.WaitForAssertion(() => Assert.Contains("Was ist 9 + 3?", cut.Markup));
}
// ---------- Prüfung ----------
[Fact]
public void Exam_hides_feedback_and_hints_and_shows_the_countdown()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
var cut = Open(attempt);
Assert.Contains("Prüfung", cut.Find(".fp-learning-mode").TextContent);
Assert.Equal("10:00", cut.Find(".fp-learning-timer-text").TextContent);
Assert.Equal("timer", cut.Find(".fp-learning-timer").GetAttribute("role"));
Assert.Equal(1, Time.ActiveTimersOf<AttemptRunner>());
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Gespeichert ✓", cut.Find(".fp-learning-save-state").TextContent));
Assert.Empty(cut.FindAll(".fp-learning-feedback")); // keine Sofort-Rückmeldung
Assert.Empty(cut.FindAll(".fp-learning-hint-button"));
Assert.Equal("1 von 3 beantwortet", Progress(cut));
}
[Fact]
public void Countdown_follows_the_server_clock_and_not_the_device_clock()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
Api.ServerNow = () => Start.AddMinutes(2); // das Gerät geht 2 Minuten nach
var cut = Open(attempt);
Assert.Equal("8:00", cut.Find(".fp-learning-timer-text").TextContent);
Time.Advance(TimeSpan.FromMinutes(1));
cut.WaitForAssertion(() => Assert.Equal("7:00", cut.Find(".fp-learning-timer-text").TextContent));
}
[Fact]
public void Countdown_ticks_every_second_turns_urgent_in_the_last_minute_and_announces_the_time()
{
var cut = Open(Attempt(LearningMode.Exam, Start.AddMinutes(10)));
Time.Advance(TimeSpan.FromSeconds(5));
cut.WaitForAssertion(() => Assert.Equal("9:55", cut.Find(".fp-learning-timer-text").TextContent));
Assert.DoesNotContain("is-urgent", cut.Find(".fp-learning-timer").ClassList);
Time.Advance(TimeSpan.FromMinutes(5).Subtract(TimeSpan.FromSeconds(5))); // 5 Minuten verbleibend
cut.WaitForAssertion(() => Assert.Equal("Noch 5 Minuten.", cut.Find(".fp-learning-announce").TextContent));
Time.Advance(TimeSpan.FromSeconds(4 * 60 + 1)); // 59 Sekunden verbleibend
cut.WaitForAssertion(() => Assert.Equal("0:59", cut.Find(".fp-learning-timer-text").TextContent));
Assert.Contains("is-urgent", cut.Find(".fp-learning-timer").ClassList);
Assert.Equal("Noch eine Minute.", cut.Find(".fp-learning-announce").TextContent);
}
[Fact]
public void Time_up_submits_automatically_and_shows_the_result()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(10));
Api.ServerNow = () => Time.GetUtcNow();
var cut = Open(attempt);
cut.Find(".fp-learning-number input").Change("12");
Assert.Empty(Api.Submitted);
Time.Advance(TimeSpan.FromMinutes(10).Subtract(TimeSpan.FromSeconds(1)));
Assert.Empty(Api.Submitted); // eine Sekunde vor Ablauf wird noch nichts abgegeben
Time.Advance(TimeSpan.FromSeconds(1));
cut.WaitForAssertion(() => Assert.Equal([attempt.Id], Api.Submitted));
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.Contains("Die Zeit ist abgelaufen – deine Antworten wurden abgegeben.", cut.Markup); // Hinweis (Snackbar)
Assert.Empty(cut.FindAll(".fp-learning-timer"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>()); // Uhr gestoppt
Assert.Empty(cut.FindAll(".fp-learning-question-card"));
}
[Fact]
public void Auto_submit_without_confirmation_and_retry_after_a_network_error()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(1));
Api.ServerNow = () => Time.GetUtcNow();
var cut = Open(attempt);
Api.Offline = true;
Time.Advance(TimeSpan.FromMinutes(1));
cut.WaitForAssertion(() => Assert.NotEmpty(Api.Submitted)); // Versuch läuft, scheitert wegen fehlender Verbindung
Assert.DoesNotContain("Wirklich abgeben?", cut.Markup); // nie eine Rückfrage bei Zeitablauf
Assert.Empty(cut.FindAll(".fp-learning-score"));
var attempts = Api.Submitted.Count;
Api.Offline = false;
Time.Advance(TimeSpan.FromSeconds(1)); // der Timer läuft weiter und versucht es erneut
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.True(Api.Submitted.Count > attempts);
}
[Fact]
public void Saving_after_the_deadline_is_rejected_by_the_server_and_the_result_is_loaded()
{
var attempt = Attempt(LearningMode.Exam, Start.AddMinutes(1));
var cut = Open(attempt);
// Der Server hat den Versuch inzwischen abgegeben und lehnt Speicherungen mit 409 ab.
Api.SaveBehavior = (_, _, _) => ApiResult<SaveAnswerResultDto>.Fail(new ApiProblem { Status = 409, Detail = "Die Zeit ist abgelaufen. Der Versuch wurde abgegeben." });
Api.Attempts[attempt.Id] = attempt with
{
Status = LearningAttemptStatus.Submitted,
AutoSubmitted = true,
Result = new MyLearningResultDto(attempt.Id, 1, LearningAttemptStatus.Submitted, Start.AddMinutes(1), true, 0, 4, 0, null, false),
};
cut.Find(".fp-learning-number input").Change("12");
cut.WaitForAssertion(() => Assert.Contains("Die Zeit ist abgelaufen – abgegeben", cut.Markup));
Assert.Contains("Die Zeit ist abgelaufen. Der Versuch wurde abgegeben.", cut.Markup);
Assert.Empty(cut.FindAll(".fp-learning-question-card"));
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
}
[Fact]
public async Task Disposing_the_runner_stops_the_clock()
{
var cut = Open(Attempt(LearningMode.Exam, Start.AddMinutes(10)));
Assert.Equal(1, Time.ActiveTimersOf<AttemptRunner>());
cut.Render(p => p.AddChildContent("<span>weg</span>"));
await Task.Yield();
Assert.Equal(0, Time.ActiveTimersOf<AttemptRunner>());
Time.Advance(TimeSpan.FromMinutes(11)); // würde sonst abgeben
Assert.Empty(Api.Submitted);
}
}
@@ -0,0 +1,215 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Kinderansicht „Lernen“: Karten mit Status, Aktionen je Zustand, Prüfungsstart mit Bestätigung, Ergebnis, Live-Aktualisierung.</summary>
public sealed class KidOverviewTests : LearningTestBase
{
private static readonly DateOnly Today = new(2026, 10, 12);
private static MyLearningSetDto Set(
string title,
LearningSetStatus status = LearningSetStatus.Assigned,
LearningMode mode = LearningMode.Practice,
bool canStart = true,
Guid? running = null,
int used = 0,
int max = 0,
int? timeLimit = null,
DateOnly? due = null,
int pointsCredited = 0,
params MyLearningResultDto[] results) =>
new(Guid.NewGuid(), title, LearningSubjects.Math, 3, due, status, mode, timeLimit, 5, 10, max, used, canStart, running, pointsCredited, results);
private static MyLearningResultDto Result(int earned, int total, bool final, int? grade = null, int open = 0) =>
new(Guid.NewGuid(), 1, final ? LearningAttemptStatus.Reviewed : LearningAttemptStatus.Submitted, FakeLearningApi.Now, false, earned, total, open, grade, final);
private IRenderedComponent<LearningHost> Open()
{
SignInAs(Roles.Child);
return Render<LearningHost>(p => p.AddChildContent<KidOverview>());
}
[Fact]
public void Open_sheets_show_meta_and_the_right_action_per_state()
{
var fresh = Set("Plus und Minus", due: Today, timeLimit: null);
var running = Set("Brüche", LearningSetStatus.InProgress, running: Guid.NewGuid(), used: 1, max: 3);
var exam = Set("Klassenarbeit", mode: LearningMode.Exam, timeLimit: 20, max: 1);
var done = Set("Deutsch-Test", LearningSetStatus.Reviewed, canStart: false, used: 1, max: 1, pointsCredited: 8, results: Result(4, 5, true, 2));
Api.Mine.AddRange([fresh, running, exam, done]);
var cut = Open();
Assert.Equal("Hallo Mia! Hier sind deine Übungsblätter.", cut.Find(".fp-learning-greeting").TextContent);
var open = cut.Find("ul[aria-label='Offene Blätter']").QuerySelectorAll(".fp-learning-card");
Assert.Equal(3, open.Length);
var freshCard = cut.Find($"[data-set-id='{fresh.Id}']");
Assert.Contains("fällig heute", freshCard.TextContent);
Assert.Contains("5 Aufgaben", freshCard.TextContent);
Assert.Equal("Los geht's", Text(freshCard.QuerySelector(".fp-learning-start")!));
Assert.Null(freshCard.QuerySelector(".fp-learning-result-link"));
var runningCard = cut.Find($"[data-set-id='{running.Id}']");
Assert.Equal("Weitermachen", Text(runningCard.QuerySelector(".fp-learning-continue")!));
Assert.Equal($"lernen/versuch/{running.RunningAttemptId}", runningCard.QuerySelector(".fp-learning-continue")!.GetAttribute("href"));
Assert.Contains("Versuch 1 von 3", runningCard.TextContent);
Assert.Null(runningCard.QuerySelector(".fp-learning-start"));
var examCard = cut.Find($"[data-set-id='{exam.Id}']");
Assert.Contains("Prüfung · 20 Min.", examCard.QuerySelector(".fp-learning-mode")!.TextContent);
var finished = cut.Find("ul[aria-label='Abgegebene Blätter']").QuerySelectorAll(".fp-learning-card");
var doneCard = Assert.Single(finished);
Assert.Null(doneCard.QuerySelector(".fp-learning-start"));
Assert.Contains("Geprüft: 4 Punkte von 5 · Note 2 – gut", doneCard.QuerySelector(".fp-learning-result")!.TextContent);
Assert.Contains("8 Punkte gutgeschrieben", doneCard.TextContent);
Assert.Equal($"lernen/versuch/{done.Results[0].AttemptId}", doneCard.QuerySelector(".fp-learning-result-link")!.GetAttribute("href"));
}
[Fact]
public void Submitted_results_are_marked_as_pending_and_repeatable_sheets_offer_another_try()
{
var set = Set("Wiederholen", LearningSetStatus.Submitted, canStart: true, used: 1, max: 3, results: Result(3, 5, false, open: 1));
Api.Mine.Add(set);
var cut = Open();
var card = cut.Find($"[data-set-id='{set.Id}']");
Assert.Contains("Abgegeben: 3 Punkte von 5 (wird von Mama oder Papa geprüft)", card.QuerySelector(".fp-learning-result")!.TextContent);
Assert.Equal("Nochmal versuchen", Text(card.QuerySelector(".fp-learning-start")!));
Assert.Contains("Versuch 2 von 3", card.TextContent);
}
[Fact]
public void Overdue_open_sheets_are_highlighted()
{
var late = Set("Zu spät", due: Today.AddDays(-2));
var fine = Set("Rechtzeitig", due: Today.AddDays(3));
Api.Mine.AddRange([late, fine]);
var cut = Open();
Assert.Contains("is-overdue", cut.Find($"[data-set-id='{late.Id}']").ClassList);
Assert.DoesNotContain("is-overdue", cut.Find($"[data-set-id='{fine.Id}']").ClassList);
Assert.Contains("überfällig seit 10.10.", cut.Find($"[data-set-id='{late.Id}'] .fp-learning-due").TextContent);
// Reihenfolge: früheste Fälligkeit zuerst (Sortierung der Api bleibt erhalten)
var titles = cut.FindAll(".fp-learning-card .fp-learning-title").Select(t => t.TextContent).ToList();
Assert.Contains("Zu spät", titles[0]);
Assert.Contains("Rechtzeitig", titles[1]);
}
[Fact]
public void Starting_a_practice_sheet_creates_an_attempt_and_navigates_to_it()
{
var set = Set("Üben");
Api.Mine.Add(set);
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1+1?")]) with { SetId = set.Id };
Api.Attempts[attempt.Id] = attempt;
var cut = Open();
cut.Find(".fp-learning-start").Click();
Assert.Equal([set.Id], Api.Started);
Assert.EndsWith($"lernen/versuch/{attempt.Id}", Services.GetRequiredService<Microsoft.AspNetCore.Components.NavigationManager>().Uri);
}
[Fact]
public void Starting_an_exam_asks_for_confirmation_and_explains_the_rules()
{
var set = Set("Klassenarbeit", mode: LearningMode.Exam, timeLimit: 25, max: 1);
Api.Mine.Add(set);
var attempt = FakeLearningApi.NewAttempt("Klassenarbeit", LearningMode.Exam, [FakeLearningApi.AttemptItem(0, "1+1?")], FakeLearningApi.Now.AddMinutes(25)) with { SetId = set.Id };
Api.Attempts[attempt.Id] = attempt;
var cut = Open();
var click = cut.InvokeAsync(() => cut.Find(".fp-learning-start").Click());
cut.WaitForAssertion(() => Assert.Contains("Prüfung beginnen?", cut.Markup));
Assert.Contains("Du hast 25 Minuten. Die Uhr startet sofort", cut.Markup);
Assert.Empty(Api.Started); // noch nichts gestartet – die Uhr läuft erst nach der Bestätigung
// „Noch nicht“ bricht ab
cut.FindAll("button").First(b => Text(b) == "Noch nicht").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Prüfung beginnen?", cut.Markup));
Assert.Empty(Api.Started);
// Bestätigen startet den Versuch
var second = cut.InvokeAsync(() => cut.Find(".fp-learning-start").Click());
cut.WaitForAssertion(() => Assert.Contains("Prüfung beginnen?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Prüfung beginnen").Click();
cut.WaitForAssertion(() => Assert.Equal([set.Id], Api.Started));
Assert.EndsWith($"lernen/versuch/{attempt.Id}", Services.GetRequiredService<Microsoft.AspNetCore.Components.NavigationManager>().Uri);
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(second.IsCompleted || !second.IsFaulted);
}
[Fact]
public void A_failed_start_is_reported_and_the_list_reloads()
{
var set = Set("Aufgebraucht");
Api.Mine.Add(set);
var cut = Open();
var before = Api.GetMineCalls;
cut.Find(".fp-learning-start").Click(); // die Fake-Api lehnt ab (kein laufender Versuch)
cut.WaitForAssertion(() => Assert.Contains("Alle erlaubten Versuche sind aufgebraucht.", cut.Markup));
Assert.True(Api.GetMineCalls > before);
}
[Fact]
public void Empty_state_error_state_and_live_updates()
{
var cut = Open();
Assert.Contains("Gerade gibt es kein Übungsblatt für dich.", cut.Find(".fp-learning-empty").TextContent);
// Live: neues Blatt zugewiesen → Liste lädt neu
var set = Set("Neu zugewiesen");
Api.Mine.Add(set);
cut.InvokeAsync(() => Live.RaiseLearningChanged(new LearningChangedDto(set.Id, LearningChangeKind.Assigned)));
cut.WaitForAssertion(() => Assert.NotNull(cut.Find($"[data-set-id='{set.Id}']")));
}
[Fact]
public void Load_errors_offer_a_retry()
{
Api.Offline = true;
var cut = Open();
Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
Api.Mine.Add(Set("Wieder da"));
cut.FindAll(".fp-learning-error button").Single().Click();
cut.WaitForAssertion(() => Assert.Contains("Wieder da", cut.Markup));
}
[Fact]
public void Everything_done_shows_a_friendly_message_without_open_sheets()
{
Api.Mine.Add(Set("Fertig", LearningSetStatus.Reviewed, canStart: false, results: Result(5, 5, true)));
var cut = Open();
Assert.Contains("Alles erledigt", cut.Find(".fp-learning-nothing-open").TextContent);
Assert.Empty(cut.FindAll("ul[aria-label='Offene Blätter']"));
}
[Fact]
public async Task Disposing_unsubscribes_from_live_events()
{
Api.Mine.Add(Set("Eins"));
var cut = Open();
var calls = Api.GetMineCalls;
cut.Render(p => p.AddChildContent("<span>leer</span>")); // ersetzt den Inhalt: KidOverview wird entsorgt
await Live.RaiseLearningChanged(new LearningChangedDto(Guid.NewGuid(), LearningChangeKind.Assigned));
Assert.Equal(calls, Api.GetMineCalls);
}
}
@@ -0,0 +1,438 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Admin;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Services;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>
/// TimeProvider mit von Hand steuerbarer Zeit <b>und</b> Timern: <see cref="Advance"/> lässt die Uhr in Sekundenschritten laufen und löst fällige Timer aus.
/// So lässt sich der Prüfungs-Countdown ohne echtes Warten testen.
/// </summary>
internal sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
{
private readonly List<ManualTimer> _timers = [];
private DateTimeOffset _now = start;
/// <summary>Laufende Timer der Komponente <typeparamref name="T"/> (MudBlazor legt eigene Timer an, die hier nicht zählen).</summary>
public int ActiveTimersOf<T>() => _timers.Count(t => !t.Disposed && t.Callback.Target is T);
public override DateTimeOffset GetUtcNow() => _now;
public override TimeZoneInfo LocalTimeZone => FamilyPlanner.Domain.Common.FamilyTime.Berlin;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ManualTimer(callback, state, _now + dueTime, period);
_timers.Add(timer);
return timer;
}
/// <summary>Uhr um <paramref name="by"/> vorstellen; Timer feuern in der richtigen Reihenfolge (Schrittweite höchstens 1 s).</summary>
public void Advance(TimeSpan by)
{
var target = _now + by;
while (_now < target)
{
var step = target - _now > TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : target - _now;
_now += step;
foreach (var timer in _timers.Where(t => !t.Disposed).ToList())
{
timer.FireIfDue(_now);
}
}
}
private sealed class ManualTimer(TimerCallback timerCallback, object? state, DateTimeOffset next, TimeSpan period) : ITimer
{
private DateTimeOffset _next = next;
public TimerCallback Callback { get; } = timerCallback;
public bool Disposed { get; private set; }
public void FireIfDue(DateTimeOffset now)
{
while (!Disposed && _next <= now)
{
_next = period > TimeSpan.Zero ? _next + period : DateTimeOffset.MaxValue;
Callback(state);
}
}
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
public void Dispose() => Disposed = true;
public ValueTask DisposeAsync()
{
Disposed = true;
return ValueTask.CompletedTask;
}
}
}
/// <summary>In-Memory-„Server“ der Lernaufgaben für bUnit: Eltern- und Kinderseite, mit Aufrufprotokoll.</summary>
internal sealed class FakeLearningApi : ILearningApi
{
public static readonly Guid Me = Guid.Parse("00000000-0000-0000-0000-0000000000a1");
public static readonly Guid Mia = Guid.Parse("00000000-0000-0000-0000-0000000000a2");
public static readonly Guid Ben = Guid.Parse("00000000-0000-0000-0000-0000000000a3");
public static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
// ---- Eltern ----
public List<LearningSetDto> Sets { get; } = [];
public Dictionary<Guid, LearningSetDetailDto> Details { get; } = [];
public List<LearningReviewQueueItemDto> Queue { get; } = [];
public Dictionary<Guid, LearningAttemptReviewDto> Reviews { get; } = [];
public List<UserDirectoryEntry> Users { get; } =
[
new(Me, "Papa", "#1E88E5", null, Roles.Family),
new(Mia, "Mia", "#E53935", null, Roles.Child),
new(Ben, "Ben", "#43A047", null, Roles.Child),
];
// ---- Kind ----
public List<MyLearningSetDto> Mine { get; } = [];
public Dictionary<Guid, LearningAttemptDto> Attempts { get; } = [];
// ---- Verhalten ----
public bool Offline { get; set; }
/// <summary>Antwort der Api auf das Speichern einer Antwort; Standard: gespeichert, Übung → „richtig“.</summary>
public Func<Guid, Guid, string?, ApiResult<SaveAnswerResultDto>>? SaveBehavior { get; set; }
/// <summary>Antwort der Api auf die Abgabe; Standard: Versuch als abgegeben markieren.</summary>
public Func<Guid, ApiResult<LearningAttemptDto>>? SubmitBehavior { get; set; }
/// <summary>Serveruhr (für ServerNow der Antworten).</summary>
public Func<DateTimeOffset> ServerNow { get; set; } = () => Now;
// ---- Protokoll ----
public List<CreateLearningSetRequest> Created { get; } = [];
public List<(Guid Id, UpdateLearningSetRequest Request)> Updated { get; } = [];
public List<Guid> Deleted { get; } = [];
public List<Guid> Assigned { get; } = [];
public List<Guid> Unassigned { get; } = [];
public List<(Guid Set, LearningItemInput Item)> AddedItems { get; } = [];
public List<(Guid Set, Guid Item, UpdateLearningItemRequest Request)> UpdatedItems { get; } = [];
public List<(Guid Set, Guid Item)> DeletedItems { get; } = [];
public List<(Guid Set, IReadOnlyList<Guid> Order)> Reordered { get; } = [];
public List<(Guid Attempt, Guid Item, ReviewAnswerRequest Request)> Reviewed { get; } = [];
public List<(Guid Attempt, ConfirmAttemptRequest Request)> Confirmed { get; } = [];
public List<Guid> Reopened { get; } = [];
public List<Guid> Started { get; } = [];
public List<(Guid Attempt, Guid Item, string? Value)> Saved { get; } = [];
public List<Guid> Submitted { get; } = [];
public int GetAttemptCalls { get; private set; }
public int GetMineCalls { get; private set; }
public int GetSetsCalls { get; private set; }
private int _version;
// ---------- Aufbau ----------
public static LearningItemDto Item(int order, string question, LearningAnswerType type = LearningAnswerType.Number, string? correct = "12", int points = 1,
IReadOnlyList<string>? options = null, string? hint = null, string? explanation = null) =>
new(Guid.NewGuid(), order, question, type, correct, null, options ?? [], hint, explanation, 1, points, [1]);
public LearningSetDetailDto AddSet(
string title,
LearningSetStatus status = LearningSetStatus.Draft,
LearningMode mode = LearningMode.Practice,
Guid? child = null,
int attempts = 0,
int pending = 0,
int itemCount = 2,
int? timeLimit = null,
int totalPoints = 10,
DateOnly? due = null,
string subject = LearningSubjects.Math)
{
var items = Enumerable.Range(0, itemCount).Select(i => Item(i, $"Frage {i + 1} von {title}")).ToList();
var set = new LearningSetDto(
Guid.NewGuid(), title, subject, 3, child ?? Mia, due, status, mode, 0, timeLimit, true, false, totalPoints, null, LearningSource.Manual, null, null, null,
status == LearningSetStatus.Draft ? null : Now, itemCount, attempts, pending, 0, Now, Now, [1]);
var detail = new LearningSetDetailDto(set, items, []);
Sets.Add(set);
Details[set.Id] = detail;
return detail;
}
public LearningReviewQueueItemDto AddQueueEntry(LearningSetDto set, string child, int open = 0, bool auto = false)
{
var entry = new LearningReviewQueueItemDto(Guid.NewGuid(), set.Id, set.Title, set.Subject, set.Mode, set.TargetUserId, child, 1, Now.AddMinutes(-5), auto, open, 3, 4);
Queue.Add(entry);
return entry;
}
// ---------- Eltern ----------
public Task<ApiResult<List<LearningSetDto>>> GetSetsAsync(CancellationToken ct = default)
{
GetSetsCalls++;
return Result(() => Sets.ToList());
}
public Task<ApiResult<LearningSetDetailDto>> GetSetAsync(Guid id, CancellationToken ct = default) =>
Details.TryGetValue(id, out var detail)
? Result(() => detail)
: Task.FromResult(ApiResult<LearningSetDetailDto>.Fail(new ApiProblem { Status = 404 }));
public Task<ApiResult<LearningSetDetailDto>> CreateSetAsync(CreateLearningSetRequest request, CancellationToken ct = default)
{
Created.Add(request);
var detail = AddSet(request.Title, mode: request.Mode, child: request.TargetUserId, itemCount: 0, timeLimit: request.TimeLimitMinutes, totalPoints: request.TotalPoints);
return Result(() => detail);
}
public Task<ApiResult<LearningSetDetailDto>> UpdateSetAsync(Guid id, UpdateLearningSetRequest request, CancellationToken ct = default)
{
Updated.Add((id, request));
var detail = Details[id];
if (request.Version is { } version && !version.SequenceEqual(detail.Set.Version))
{
return Task.FromResult(new ApiResult<LearningSetDetailDto>(detail, new ApiProblem { Status = 409, Detail = "Das Blatt wurde inzwischen von jemand anderem geändert." }));
}
var set = detail.Set with
{
Title = request.Title, Subject = request.Subject, GradeLevel = request.GradeLevel, TargetUserId = request.TargetUserId, DueDate = request.DueDate,
Mode = request.Mode, MaxAttempts = request.MaxAttempts, TimeLimitMinutes = request.TimeLimitMinutes, ShowSolutionsAfterSubmit = request.ShowSolutionsAfterSubmit,
ShuffleItems = request.ShuffleItems, TotalPoints = request.TotalPoints, GradeThresholds = request.GradeThresholds, Version = NextVersion(),
};
return Store(detail with { Set = set });
}
public Task<ApiResult<bool>> DeleteSetAsync(Guid id, CancellationToken ct = default)
{
Deleted.Add(id);
Sets.RemoveAll(s => s.Id == id);
Details.Remove(id);
return Result(() => true);
}
public Task<ApiResult<LearningSetDetailDto>> AssignAsync(Guid id, CancellationToken ct = default)
{
Assigned.Add(id);
var detail = Details[id];
return Store(detail with { Set = detail.Set with { Status = LearningSetStatus.Assigned, AssignedAt = Now, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> UnassignAsync(Guid id, CancellationToken ct = default)
{
Unassigned.Add(id);
var detail = Details[id];
return Store(detail with { Set = detail.Set with { Status = LearningSetStatus.Draft, AssignedAt = null, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> AddItemAsync(Guid setId, LearningItemInput request, CancellationToken ct = default)
{
AddedItems.Add((setId, request));
var detail = Details[setId];
var item = new LearningItemDto(Guid.NewGuid(), detail.Items.Count, request.Question, request.AnswerType, request.CorrectAnswer, request.NumberTolerance,
request.Options ?? [], request.Hint, request.Explanation, request.Difficulty, request.Points, NextVersion());
return Store(detail with { Items = [.. detail.Items, item], Set = detail.Set with { ItemCount = detail.Items.Count + 1, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> UpdateItemAsync(Guid setId, Guid itemId, UpdateLearningItemRequest request, CancellationToken ct = default)
{
UpdatedItems.Add((setId, itemId, request));
var detail = Details[setId];
var items = detail.Items.Select(i => i.Id != itemId ? i : i with
{
Question = request.Question, AnswerType = request.AnswerType, CorrectAnswer = request.CorrectAnswer, NumberTolerance = request.NumberTolerance,
Options = request.Options ?? [], Hint = request.Hint, Explanation = request.Explanation, Difficulty = request.Difficulty, Points = request.Points, Version = NextVersion(),
}).ToList();
return Store(detail with { Items = items, Set = detail.Set with { Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> DeleteItemAsync(Guid setId, Guid itemId, CancellationToken ct = default)
{
DeletedItems.Add((setId, itemId));
var detail = Details[setId];
var items = detail.Items.Where(i => i.Id != itemId).Select((i, index) => i with { SortOrder = index }).ToList();
return Store(detail with { Items = items, Set = detail.Set with { ItemCount = items.Count, Version = NextVersion() } });
}
public Task<ApiResult<LearningSetDetailDto>> ReorderItemsAsync(Guid setId, IReadOnlyList<Guid> itemIds, CancellationToken ct = default)
{
Reordered.Add((setId, itemIds));
var detail = Details[setId];
var items = itemIds.Select((id, index) => detail.Items.Single(i => i.Id == id) with { SortOrder = index }).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) =>
Reviews.TryGetValue(attemptId, out var review)
? Result(() => review)
: Task.FromResult(ApiResult<LearningAttemptReviewDto>.Fail(new ApiProblem { Status = 404 }));
public Task<ApiResult<LearningAttemptReviewDto>> ReviewAnswerAsync(Guid attemptId, Guid itemId, ReviewAnswerRequest request, CancellationToken ct = default)
{
Reviewed.Add((attemptId, itemId, request));
var review = Reviews[attemptId];
var answers = review.Answers.Select(a => a.ItemId != itemId ? a : a with
{
Verdict = request.Verdict,
Grader = LearningGrader.Parent,
Reason = request.Reason,
AwardedPoints = request.Verdict switch { LearningVerdict.Correct => a.ItemPoints, LearningVerdict.Partial => request.AwardedPoints ?? 1, _ => 0 },
}).ToList();
var score = LearningRules.Score(answers.Select(a => new ScoredAnswer(a.ItemPoints, a.Verdict, a.AwardedPoints)));
var updated = review with { Answers = answers, EarnedPoints = score.Earned, OpenCount = score.OpenCount };
Reviews[attemptId] = updated;
return Result(() => updated);
}
public Task<ApiResult<LearningReviewResultDto>> ConfirmAsync(Guid attemptId, ConfirmAttemptRequest request, CancellationToken ct = default)
{
Confirmed.Add((attemptId, request));
var review = Reviews[attemptId] with { Status = LearningAttemptStatus.Reviewed, ReviewNote = request.Note, PointsCredited = 7 };
Reviews[attemptId] = review;
Queue.RemoveAll(q => q.AttemptId == attemptId);
return Result(() => new LearningReviewResultDto(review, 7, 7));
}
public Task<ApiResult<LearningReviewResultDto>> ReopenAsync(Guid attemptId, CancellationToken ct = default)
{
Reopened.Add(attemptId);
var review = Reviews[attemptId] with { Status = LearningAttemptStatus.Submitted, PointsCredited = 0 };
Reviews[attemptId] = review;
return Result(() => new LearningReviewResultDto(review, -7, 0));
}
public Task<ApiResult<List<UserDirectoryEntry>>> GetUsersAsync(CancellationToken ct = default) => Result(() => Users.ToList());
// ---------- Kind ----------
public Task<ApiResult<List<MyLearningSetDto>>> GetMineAsync(CancellationToken ct = default)
{
GetMineCalls++;
return Result(() => Mine.ToList());
}
public Task<ApiResult<LearningAttemptDto>> StartAsync(Guid setId, CancellationToken ct = default)
{
Started.Add(setId);
var attempt = Attempts.Values.FirstOrDefault(a => a.SetId == setId && a.Status == LearningAttemptStatus.InProgress);
return attempt is null
? Task.FromResult(ApiResult<LearningAttemptDto>.Fail(new ApiProblem { Status = 409, Detail = "Alle erlaubten Versuche sind aufgebraucht." }))
: Result(() => attempt with { ServerNow = ServerNow() });
}
public Task<ApiResult<LearningAttemptDto>> GetAttemptAsync(Guid attemptId, CancellationToken ct = default)
{
GetAttemptCalls++;
return Attempts.TryGetValue(attemptId, out var attempt)
? Result(() => attempt with { ServerNow = ServerNow() })
: Task.FromResult(ApiResult<LearningAttemptDto>.Fail(new ApiProblem { Status = 404 }));
}
/// <summary>Hält die nächste Speicherung an, bis das Gate abgeschlossen wird (Reihenfolge-Tests).</summary>
public TaskCompletionSource? SaveGate { get; set; }
public async Task<ApiResult<SaveAnswerResultDto>> SaveAnswerAsync(Guid attemptId, Guid itemId, SaveAnswerRequest request, CancellationToken ct = default)
{
Saved.Add((attemptId, itemId, request.Value));
if (SaveGate is { } gate)
{
SaveGate = null;
await gate.Task;
}
if (Offline)
{
return ApiResult<SaveAnswerResultDto>.Fail(ApiProblem.Offline);
}
if (SaveBehavior is { } behavior)
{
return behavior(attemptId, itemId, request.Value);
}
var attempt = Attempts[attemptId];
LearningVerdict? verdict = attempt.Mode == LearningMode.Practice && request.Value is not null ? LearningVerdict.Correct : null;
Attempts[attemptId] = attempt with { Items = attempt.Items.Select(i => i.ItemId == itemId ? i with { Answer = request.Value } : i).ToList() };
return ApiResult<SaveAnswerResultDto>.Ok(new SaveAnswerResultDto(itemId, request.Value is not null, verdict, attempt.DeadlineAt, ServerNow()));
}
public Task<ApiResult<LearningAttemptDto>> SubmitAsync(Guid attemptId, CancellationToken ct = default)
{
Submitted.Add(attemptId);
if (Offline)
{
return Task.FromResult(ApiResult<LearningAttemptDto>.Fail(ApiProblem.Offline));
}
if (SubmitBehavior is { } behavior)
{
return Task.FromResult(behavior(attemptId));
}
var attempt = Attempts[attemptId];
var items = attempt.Items.Select(i => i with { Feedback = new LearningItemFeedbackDto(i.Answer is null ? LearningVerdict.Wrong : LearningVerdict.Correct, i.Answer is null ? 0 : i.Points, null, null, null) }).ToList();
var earned = items.Sum(i => i.Feedback!.AwardedPoints);
var submitted = attempt with
{
Status = LearningAttemptStatus.Submitted,
Items = items,
AutoSubmitted = attempt.DeadlineAt is { } d && ServerNow() >= d,
Result = new MyLearningResultDto(attempt.Id, attempt.Number, LearningAttemptStatus.Submitted, ServerNow(), false, earned, attempt.Items.Sum(i => i.Points), 0, null, false),
};
Attempts[attemptId] = submitted;
return Result(() => submitted);
}
// ---------- Hilfen ----------
public static LearningAttemptDto NewAttempt(
string title, LearningMode mode, IReadOnlyList<LearningAttemptItemDto> items, DateTimeOffset? deadline = null, LearningAttemptStatus status = LearningAttemptStatus.InProgress) =>
new(Guid.NewGuid(), Guid.NewGuid(), title, LearningSubjects.Math, mode, 1, status, Now, deadline, Now, false, items, null, [1]);
public static LearningAttemptItemDto AttemptItem(int position, string question, LearningAnswerType type = LearningAnswerType.Number, int points = 1,
IReadOnlyList<string>? options = null, string? hint = null, string? answer = null, LearningItemFeedbackDto? feedback = null) =>
new(Guid.NewGuid(), position, question, type, options ?? [], points, 1, answer, hint, feedback);
private Task<ApiResult<LearningSetDetailDto>> Store(LearningSetDetailDto detail)
{
Details[detail.Set.Id] = detail;
var index = Sets.FindIndex(s => s.Id == detail.Set.Id);
if (index >= 0)
{
Sets[index] = detail.Set;
}
return Result(() => detail);
}
private byte[] NextVersion() => BitConverter.GetBytes(++_version + 100);
private Task<ApiResult<T>> Result<T>(Func<T> produce) =>
Task.FromResult(Offline ? ApiResult<T>.Fail(ApiProblem.Offline) : ApiResult<T>.Ok(produce()));
}
@@ -0,0 +1,104 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
public sealed class LearningFormatTests
{
[Theory]
[InlineData(0, "0:00")]
[InlineData(-5, "0:00")]
[InlineData(1, "0:01")]
[InlineData(59, "0:59")]
[InlineData(60, "1:00")]
[InlineData(61, "1:01")]
[InlineData(600, "10:00")]
[InlineData(3599, "59:59")]
[InlineData(3600, "1:00:00")]
[InlineData(5025, "1:23:45")]
public void Countdown_shows_minutes_and_seconds(int seconds, string expected) =>
Assert.Equal(expected, LearningFormat.Countdown(TimeSpan.FromSeconds(seconds)));
[Fact]
public void Countdown_rounds_partial_seconds_up_so_the_clock_never_shows_zero_early()
{
Assert.Equal("0:01", LearningFormat.Countdown(TimeSpan.FromMilliseconds(100)));
Assert.Equal("1:00", LearningFormat.Countdown(TimeSpan.FromMilliseconds(59_100)));
}
[Theory]
[InlineData(300, "Noch 5 Minuten.")]
[InlineData(150, "Noch 3 Minuten.")]
[InlineData(120, "Noch 2 Minuten.")]
[InlineData(119, "Noch eine Minute.")]
[InlineData(60, "Noch eine Minute.")]
[InlineData(59, "Noch 59 Sekunden.")]
[InlineData(10, "Noch 10 Sekunden.")]
[InlineData(0, "Noch 0 Sekunden.")]
public void Remaining_time_is_spoken_in_german(int seconds, string expected) =>
Assert.Equal(expected, LearningFormat.RemainingSpoken(TimeSpan.FromSeconds(seconds)));
[Theory]
[InlineData(0, "fällig heute")]
[InlineData(1, "fällig morgen")]
[InlineData(3, "fällig Do., 15.10.")]
[InlineData(-1, "überfällig seit 11.10.")]
[InlineData(-30, "überfällig seit 12.09.")]
[InlineData(100, "fällig 20.01.2027")]
public void Due_dates_are_relative_to_today(int offsetDays, string expected)
{
var today = new DateOnly(2026, 10, 12);
Assert.Equal(expected, LearningFormat.Due(today.AddDays(offsetDays), today));
Assert.Equal(string.Empty, LearningFormat.Due(null, today));
}
[Fact]
public void Overdue_is_strictly_before_today()
{
var today = new DateOnly(2026, 10, 12);
Assert.False(LearningFormat.IsOverdue(today, today));
Assert.True(LearningFormat.IsOverdue(today.AddDays(-1), today));
Assert.False(LearningFormat.IsOverdue(null, today));
}
[Fact]
public void Times_are_shown_in_the_family_time_zone()
{
Assert.Equal("12.10.2026 09:55", LearningFormat.Local(new DateTimeOffset(2026, 10, 12, 7, 55, 0, TimeSpan.Zero))); // Sommerzeit
Assert.Equal("12.01.2027 08:55", LearningFormat.Local(new DateTimeOffset(2027, 1, 12, 7, 55, 0, TimeSpan.Zero))); // Winterzeit
}
[Theory]
[InlineData(LearningAnswerType.SingleChoice, "1", "Berlin")]
[InlineData(LearningAnswerType.SingleChoice, "7", "7")]
[InlineData(LearningAnswerType.SingleChoice, "x", "x")]
[InlineData(LearningAnswerType.Number, "1", "1")]
[InlineData(LearningAnswerType.Text, " ", "keine Antwort")]
[InlineData(LearningAnswerType.Text, null, "keine Antwort")]
public void Answers_are_readable(LearningAnswerType type, string? value, string expected) =>
Assert.Equal(expected, LearningFormat.AnswerText(type, ["Paris", "Berlin"], value));
[Fact]
public void Texts_are_german_and_complete()
{
foreach (var status in Enum.GetValues<LearningSetStatus>())
{
Assert.NotEqual(status.ToString(), LearningFormat.Status(status));
}
Assert.Equal(["Richtig", "Teilweise richtig", "Falsch", "Wird geprüft"], new[] { LearningVerdict.Correct, LearningVerdict.Partial, LearningVerdict.Wrong, LearningVerdict.Open }.Select(LearningFormat.Verdict));
Assert.Equal(["Übung", "Prüfung"], new[] { LearningMode.Practice, LearningMode.Exam }.Select(LearningFormat.Mode));
Assert.Equal(["Leicht", "Mittel", "Schwer"], new[] { 1, 2, 3 }.Select(LearningFormat.Difficulty));
Assert.Equal("1 Punkt", LearningFormat.Points(1));
Assert.Equal("3 Punkte", LearningFormat.Points(3));
Assert.Equal("Note 2 – gut", LearningFormat.Grade(2));
Assert.Equal(["beliebig oft", "1 Versuch", "3 Versuche"], new[] { 0, 1, 3 }.Select(LearningFormat.Attempts));
Assert.Equal("3. Klasse", LearningFormat.GradeLevel(3));
Assert.Equal("ohne Zeitlimit", LearningFormat.TimeLimit(null));
Assert.Equal("20 Min.", LearningFormat.TimeLimit(20));
Assert.Contains("Du hast 20 Minuten", LearningFormat.ExamRules(20));
Assert.DoesNotContain("Minuten", LearningFormat.ExamRules(null));
}
}
@@ -0,0 +1,10 @@
@* Testhülle: MudBlazor-Provider (Dialoge, Meldungen, Menüs) um eine beliebige Komponente. *@
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
@ChildContent
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,127 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Web.Client.Pages;
using LearningPage = FamilyPlanner.Web.Client.Pages.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>
/// Die Seiten wählen die Ansicht nach den wirksamen Berechtigungen (<c>LearningManage</c>/<c>LearningSolve</c>) und rufen ohne Recht keine Api auf.
/// Erzwungen wird serverseitig (Integrationstests) – das hier prüft, dass die Oberfläche nichts Falsches anbietet.
/// </summary>
public sealed class LearningPagesTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open<TPage>(string role, Action<ComponentParameterCollectionBuilder<TPage>>? parameters = null)
where TPage : Microsoft.AspNetCore.Components.IComponent
{
SignInAs(role, "Test", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<TPage>(parameters));
}
[Theory]
[InlineData(Roles.Admin)]
[InlineData(Roles.Family)]
public void Parents_see_the_management_view(string role)
{
var cut = Open<LearningPage>(role);
Assert.NotEmpty(cut.FindAll(".fp-learning-parent"));
Assert.Empty(cut.FindAll(".fp-learning-kid"));
Assert.NotEmpty(cut.FindAll(".fp-learning-new"));
Assert.Equal(0, Api.GetMineCalls);
}
[Fact]
public void Children_see_only_their_own_sheets_and_no_management_controls()
{
Api.AddSet("Nur für Eltern sichtbar", LearningSetStatus.Assigned);
var cut = Open<LearningPage>(Roles.Child);
Assert.NotEmpty(cut.FindAll(".fp-learning-kid"));
Assert.Empty(cut.FindAll(".fp-learning-parent"));
Assert.Empty(cut.FindAll(".fp-learning-new"));
Assert.Empty(cut.FindAll(".fp-learning-tab-review"));
Assert.Equal(0, Api.GetSetsCalls); // Kinder rufen die Verwaltungs-Api nicht auf
Assert.DoesNotContain("Nur für Eltern sichtbar", cut.Markup);
}
[Fact]
public void Guests_get_a_short_message_and_no_api_calls()
{
var cut = Open<LearningPage>(Roles.Guest);
Assert.Contains("nicht freigeschaltet", cut.Find(".fp-learning-no-access").TextContent);
Assert.Equal((0, 0), (Api.GetSetsCalls, Api.GetMineCalls));
}
[Theory]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public void Editor_and_review_pages_are_parent_only(string role)
{
var detail = Api.AddSet("Geheim");
var editor = Open<LearningSetPage>(role, p => p.Add(x => x.SetId, detail.Set.Id));
Assert.Contains("nur Eltern", editor.Find(".fp-learning-no-access").TextContent);
Assert.Empty(editor.FindAll(".fp-learning-editor"));
Assert.DoesNotContain("Geheim", editor.Markup);
}
[Theory]
[InlineData(Roles.Child)]
[InlineData(Roles.Guest)]
public void Review_page_is_parent_only(string role)
{
var cut = Open<LearningReviewPage>(role, p => p.Add(x => x.AttemptId, Guid.NewGuid()));
Assert.Contains("nur Eltern", cut.Find(".fp-learning-no-access").TextContent);
Assert.Empty(cut.FindAll(".fp-learning-review"));
}
[Fact]
public void Attempt_page_is_for_solvers_and_not_for_guests()
{
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1 + 1?")]);
Api.Attempts[attempt.Id] = attempt;
var asChild = Open<LearningAttemptPage>(Roles.Child, p => p.Add(x => x.AttemptId, attempt.Id));
Assert.NotEmpty(asChild.FindAll(".fp-learning-runner"));
Assert.Contains("1 + 1?", asChild.Markup);
}
[Fact]
public void Guests_never_reach_the_runner()
{
var attempt = FakeLearningApi.NewAttempt("Üben", LearningMode.Practice, [FakeLearningApi.AttemptItem(0, "1 + 1?")]);
Api.Attempts[attempt.Id] = attempt;
var cut = Open<LearningAttemptPage>(Roles.Guest, p => p.Add(x => x.AttemptId, attempt.Id));
Assert.NotEmpty(cut.FindAll(".fp-learning-no-access"));
Assert.Empty(cut.FindAll(".fp-learning-runner"));
Assert.Equal(0, Api.GetAttemptCalls);
}
[Fact]
public void New_sheet_route_opens_an_empty_editor_for_parents()
{
var cut = Open<LearningSetPage>(Roles.Family);
Assert.Equal("Neues Übungsblatt", cut.Find("h1").TextContent);
}
[Theory]
[InlineData(Roles.Admin, true, true)]
[InlineData(Roles.Family, true, true)]
[InlineData(Roles.Child, false, true)]
[InlineData(Roles.Guest, false, false)]
public void Access_helpers_follow_the_permission_matrix(string role, bool manage, bool solve)
{
var user = new FamilyPlanner.Shared.Auth.CurrentUserDto(
Guid.NewGuid(), "x", "X", null, "#000000", [role], false, null, null, FamilyPlanner.Domain.Authorization.PermissionMatrix.Effective([role]));
Assert.Equal((manage, solve), (FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanManage(user), FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanSolve(user)));
Assert.False(FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanManage(null));
Assert.False(FamilyPlanner.Web.Client.Components.Learning.LearningAccess.CanSolve(null));
}
}
@@ -0,0 +1,37 @@
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Shared.Auth;
using FamilyPlanner.Web.Client.Auth;
using FamilyPlanner.Web.Client.Services;
using FamilyPlanner.Web.Tests.Shopping;
using FamilyPlanner.Web.Tests.Tasks;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Gemeinsamer Aufbau der Lern-Tests: Fake-Api, Live-Verbindung, steuerbare Zeit und Anmeldung mit Rolle.</summary>
public abstract class LearningTestBase : BunitContext
{
internal FakeLearningApi Api { get; } = new();
internal FakeLiveConnection Live { get; } = new();
internal ManualTimeProvider Time { get; } = new(FakeLearningApi.Now);
protected LearningTestBase()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<ILearningApi>(Api);
Services.AddSingleton<ILiveConnection>(Live);
Services.AddSingleton<IAuthService, NoopAuthService>();
Services.AddSingleton<ApiAuthenticationStateProvider>();
Services.AddSingleton<TimeProvider>(Time);
}
protected void SignInAs(string role, string name = "Mia", Guid? id = null) =>
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
id ?? FakeLearningApi.Mia, "mia", name, null, "#E53935", [role], false, null, null, PermissionMatrix.Effective([role])));
protected static string Text(AngleSharp.Dom.IElement element) => element.TextContent.Replace(' ', ' ').Trim();
}
@@ -0,0 +1,154 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Elternansicht „Lernen“: Blätter verwalten, Prüf-Warteschlange, Löschen mit Bestätigung, Live-Aktualisierung.</summary>
public sealed class ParentOverviewTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open(string role = Roles.Family)
{
SignInAs(role, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<ParentOverview>());
}
[Fact]
public void Sets_are_listed_with_status_child_mode_counts_and_pending_reviews()
{
var draft = Api.AddSet("Entwurf Mathe", LearningSetStatus.Draft, itemCount: 3);
var exam = Api.AddSet("Klassenarbeit", LearningSetStatus.Submitted, LearningMode.Exam, FakeLearningApi.Ben, attempts: 1, pending: 1, timeLimit: 30, due: new DateOnly(2026, 10, 14));
Api.AddSet("Diktat", LearningSetStatus.Reviewed, subject: LearningSubjects.German, attempts: 2);
var cut = Open();
var rows = cut.FindAll(".fp-learning-list-item");
Assert.Equal(3, rows.Count);
var draftRow = cut.Find($"[data-set-id='{draft.Set.Id}']");
Assert.Equal("Entwurf", Text(draftRow.QuerySelector(".fp-learning-status")!));
Assert.Contains("Mia", draftRow.TextContent);
Assert.Contains("3 Aufgaben", draftRow.TextContent);
Assert.Equal($"lernen/blatt/{draft.Set.Id}", draftRow.QuerySelector("a.fp-learning-title")!.GetAttribute("href"));
var examRow = cut.Find($"[data-set-id='{exam.Set.Id}']");
Assert.Equal("Abgegeben", Text(examRow.QuerySelector(".fp-learning-status")!));
Assert.Contains("Ben", examRow.TextContent);
Assert.Contains("Prüfung · 30 Min.", examRow.TextContent);
Assert.Contains("1 zu prüfen", examRow.QuerySelector(".fp-learning-pending")!.TextContent);
Assert.Contains("fällig Mi., 14.10.", examRow.TextContent);
Assert.Contains("Blätter (3)", cut.Find(".fp-learning-tab-sets").TextContent);
Assert.Equal("lernen/neu", cut.Find(".fp-learning-new").GetAttribute("href"));
}
[Fact]
public void Empty_state_invites_to_create_the_first_sheet()
{
var cut = Open();
Assert.Contains("Noch keine Übungsblätter. Lege das erste Blatt an.", cut.Find(".fp-learning-empty").TextContent);
Assert.Contains("Zu prüfen (0)", cut.Find(".fp-learning-tab-review").TextContent);
}
[Fact]
public void Review_queue_shows_submissions_with_open_answers_and_time_expiry()
{
var set = Api.AddSet("Mathe-Test", LearningSetStatus.Submitted, attempts: 2, pending: 2).Set;
var openEntry = Api.AddQueueEntry(set, "Mia", open: 2);
var autoEntry = Api.AddQueueEntry(set, "Ben", auto: true);
var cut = Open();
Assert.Contains("Zu prüfen (2)", cut.Find(".fp-learning-tab-review").TextContent);
cut.Find(".fp-learning-tab-review").Click();
var items = cut.FindAll(".fp-learning-queue-item");
Assert.Equal(2, items.Count);
Assert.Contains("Mia – Mathe-Test", items[0].TextContent);
Assert.Contains("2 Antworten offen", items[0].QuerySelector(".fp-learning-open")!.TextContent);
Assert.Equal($"lernen/pruefen/{openEntry.AttemptId}", items[0].QuerySelector(".fp-learning-review-button")!.GetAttribute("href"));
Assert.Contains("3 Punkte von 4", items[0].TextContent);
Assert.Contains("abgegeben 12.10.2026 09:55", items[0].TextContent); // 07:55 UTC = 09:55 Europe/Berlin
Assert.Empty(items[0].QuerySelectorAll(".fp-learning-auto"));
Assert.Contains("Zeit abgelaufen", items[1].QuerySelector(".fp-learning-auto")!.TextContent);
Assert.Equal($"lernen/pruefen/{autoEntry.AttemptId}", items[1].QuerySelector(".fp-learning-review-button")!.GetAttribute("href"));
}
[Fact]
public void Empty_review_queue_is_celebrated()
{
var cut = Open();
cut.Find(".fp-learning-tab-review").Click();
Assert.Contains("Nichts zu prüfen", cut.Find(".fp-learning-queue-empty").TextContent);
}
[Fact]
public void Deleting_asks_for_confirmation_and_removes_the_sheet()
{
var keep = Api.AddSet("Bleibt");
var remove = Api.AddSet("Weg damit", LearningSetStatus.Assigned, attempts: 1);
var cut = Open();
var click = cut.InvokeAsync(() => cut.Find($"[data-set-id='{remove.Set.Id}'] .fp-learning-delete").Click());
cut.WaitForAssertion(() => Assert.Contains("Blatt löschen?", cut.Markup));
Assert.Contains("„Weg damit“ wirklich löschen?", cut.Markup);
Assert.Contains("Bereits vergebene Punkte bleiben im Punktebuch bestehen.", cut.Markup); // es gibt schon Versuche
cut.FindAll("button").First(b => Text(b) == "Abbrechen").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("Blatt löschen?", cut.Markup));
Assert.Empty(Api.Deleted);
var again = cut.InvokeAsync(() => cut.Find($"[data-set-id='{remove.Set.Id}'] .fp-learning-delete").Click());
cut.WaitForAssertion(() => Assert.Contains("Blatt löschen?", cut.Markup));
cut.FindAll("button").First(b => Text(b) == "Löschen").Click();
cut.WaitForAssertion(() => Assert.Equal([remove.Set.Id], Api.Deleted));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll($"[data-set-id='{remove.Set.Id}']")));
Assert.NotEmpty(cut.FindAll($"[data-set-id='{keep.Set.Id}']"));
Assert.True(click.IsCompleted || !click.IsFaulted);
Assert.True(again.IsCompleted || !again.IsFaulted);
}
[Fact]
public void Live_changes_reload_the_lists()
{
var cut = Open();
var before = Api.GetSetsCalls;
var set = Api.AddSet("Neu von Mama", LearningSetStatus.Submitted, pending: 1).Set;
Api.AddQueueEntry(set, "Mia");
cut.InvokeAsync(() => Live.RaiseLearningChanged(new LearningChangedDto(set.Id, LearningChangeKind.Submitted)));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll($"[data-set-id='{set.Id}']")));
Assert.True(Api.GetSetsCalls > before);
Assert.Contains("Zu prüfen (1)", cut.Find(".fp-learning-tab-review").TextContent);
}
[Fact]
public void Load_errors_offer_a_retry()
{
Api.Offline = true;
var cut = Open();
Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-learning-error").TextContent);
Api.Offline = false;
Api.AddSet("Zurück");
cut.Find(".fp-learning-error button").Click();
cut.WaitForAssertion(() => Assert.Contains("Zurück", cut.Markup));
}
[Fact]
public void Overdue_open_sheets_are_flagged_for_parents_too()
{
var today = new DateOnly(2026, 10, 12);
Api.AddSet("Zu spät", LearningSetStatus.Assigned, due: today.AddDays(-1));
Api.AddSet("Alt aber geprüft", LearningSetStatus.Reviewed, due: today.AddDays(-5));
var cut = Open();
Assert.Single(cut.FindAll(".fp-learning-overdue"));
Assert.Contains("überfällig seit 11.10.", cut.Find(".fp-learning-overdue").TextContent);
}
}
@@ -0,0 +1,384 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
using FamilyPlanner.Web.Client.Components.Learning;
using FamilyPlanner.Web.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
namespace FamilyPlanner.Web.Tests.Learning;
/// <summary>Editor für Übungsblätter (Eltern): Einstellungen, Voreinstellungen je Modus, Sperre nach dem ersten Versuch, Aufgaben, Zuweisen.</summary>
public sealed class SetEditorTests : LearningTestBase
{
private IRenderedComponent<LearningHost> Open(Guid? setId = null, bool singleChild = true)
{
if (singleChild)
{
Api.Users.RemoveAll(u => u.Id == FakeLearningApi.Ben); // genau ein Kind → wird automatisch gewählt
}
SignInAs(Roles.Family, "Papa", FakeLearningApi.Me);
return Render<LearningHost>(p => p.AddChildContent<SetEditor>(c => c.Add(x => x.SetId, setId)));
}
private static void FillTitle(IRenderedComponent<LearningHost> cut, string title) => cut.Find("#set-title").Change(title);
private static void Save(IRenderedComponent<LearningHost> cut) => cut.Find("form#learning-set-form").Submit();
private static string Flat(AngleSharp.Dom.IElement element) => System.Text.RegularExpressions.Regex.Replace(element.TextContent, @"\s+", " ").Trim();
private string CurrentUri => Services.GetRequiredService<NavigationManager>().Uri;
// ---------- Neues Blatt ----------
[Fact]
public void New_sheet_starts_with_practice_presets_and_creates_a_draft()
{
var cut = Open();
Assert.Equal("Neues Übungsblatt", cut.Find("h1").TextContent);
Assert.Empty(cut.FindAll(".fp-set-items")); // Aufgaben gibt es erst nach dem ersten Speichern
Assert.Contains("Übung", cut.Find(".fp-set-mode-practice").TextContent);
Assert.Equal("true", cut.Find(".fp-set-mode-practice").GetAttribute("aria-checked"));
Assert.Equal("0", cut.Find(".fp-set-attempts input").GetAttribute("value"));
Assert.Contains("beliebig oft", cut.Find(".fp-set-attempts").TextContent);
Assert.True(cut.Find(".fp-set-solutions input").HasAttribute("checked"));
Assert.Empty(cut.FindAll(".fp-set-thresholds")); // Notenschlüssel nur für Prüfungen
FillTitle(cut, " Plus und Minus ");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal(("Plus und Minus", LearningSubjects.Math, 3, FakeLearningApi.Mia), (request.Title.Trim(), request.Subject, request.GradeLevel, request.TargetUserId));
Assert.Equal((LearningMode.Practice, 0, (int?)null, true, false, 10, (string?)null), (request.Mode, request.MaxAttempts, request.TimeLimitMinutes, request.ShowSolutionsAfterSubmit, request.ShuffleItems, request.TotalPoints, request.GradeThresholds));
var created = Api.Sets.Single();
Assert.EndsWith($"lernen/blatt/{created.Id}", CurrentUri);
}
[Fact]
public void Validation_errors_are_shown_next_to_the_fields_and_nothing_is_sent()
{
var cut = Open(singleChild: false); // zwei Kinder: keines vorausgewählt
Save(cut);
Assert.Empty(Api.Created);
Assert.Contains("Bitte einen Titel eingeben.", cut.Markup);
Assert.Contains("Bitte ein Kind auswählen.", cut.Markup);
}
[Fact]
public void Switching_to_exam_applies_exam_presets_until_the_attempts_are_changed_by_hand()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
Assert.Equal("true", cut.Find(".fp-set-mode-exam").GetAttribute("aria-checked"));
Assert.Equal("1", cut.Find(".fp-set-attempts input").GetAttribute("value"));
Assert.Contains("1 Versuch", cut.Find(".fp-set-attempts").TextContent);
Assert.NotEmpty(cut.FindAll(".fp-set-thresholds"));
Assert.False(cut.Find(".fp-set-solutions input").HasAttribute("checked")); // Prüfung: Lösungen standardmäßig nicht zeigen
FillTitle(cut, "Klassenarbeit");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal((LearningMode.Exam, 1, false), (request.Mode, request.MaxAttempts, request.ShowSolutionsAfterSubmit));
}
[Fact]
public void Manually_chosen_attempts_survive_a_mode_switch()
{
var cut = Open();
cut.Find(".fp-set-attempts input").Change("3");
cut.Find(".fp-set-mode-exam").Click();
Assert.Equal("3", cut.Find(".fp-set-attempts input").GetAttribute("value"));
}
[Fact]
public void Time_limit_and_grade_scale_are_part_of_the_request()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
cut.Find(".fp-set-timelimit-switch input").Change(true);
cut.Find(".fp-set-timelimit input").Change("25");
cut.Find(".fp-set-thresholds input").Change("90;80;70;60;50");
cut.Find(".fp-set-total input").Change("20");
FillTitle(cut, "Mit Zeit");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Created));
var request = Api.Created[0];
Assert.Equal((25, "90;80;70;60;50", 20), (request.TimeLimitMinutes, request.GradeThresholds, request.TotalPoints));
}
[Fact]
public void An_invalid_grade_scale_is_rejected_before_sending()
{
var cut = Open();
cut.Find(".fp-set-mode-exam").Click();
cut.Find(".fp-set-thresholds input").Change("30;50;67;81;92");
FillTitle(cut, "Falscher Schlüssel");
Save(cut);
Assert.Empty(Api.Created);
Assert.Contains("müssen von Note 1 bis 5 abnehmen", cut.Markup);
}
// ---------- Bearbeiten ----------
[Fact]
public void Existing_sheets_are_loaded_edited_with_the_version_and_conflicts_are_reported()
{
var detail = Api.AddSet("Bruchrechnen", LearningSetStatus.Draft, LearningMode.Practice, itemCount: 2, timeLimit: null, totalPoints: 12);
var cut = Open(detail.Set.Id);
Assert.Equal("Übungsblatt", cut.Find("h1").TextContent);
Assert.Equal("Bruchrechnen", cut.Find("#set-title").GetAttribute("value"));
Assert.Equal("Entwurf", Text(cut.Find(".fp-learning-status")));
Assert.Equal("12", cut.Find(".fp-set-total input").GetAttribute("value"));
FillTitle(cut, "Bruchrechnen 2");
Save(cut);
cut.WaitForAssertion(() => Assert.Single(Api.Updated));
Assert.Equal(("Bruchrechnen 2", detail.Set.Version), (Api.Updated[0].Request.Title, Api.Updated[0].Request.Version));
// Jemand anderes ändert zwischenzeitlich: unsere Version ist veraltet → 409 mit Hinweis, danach neue Version
var current = Api.Details[detail.Set.Id];
Api.Details[detail.Set.Id] = current with { Set = current.Set with { Version = [9, 9] } };
FillTitle(cut, "Bruchrechnen 3");
Save(cut);
cut.WaitForAssertion(() => Assert.Contains("Das Blatt wurde inzwischen von jemand anderem geändert.", cut.Find(".fp-learning-conflict").TextContent));
Assert.Equal("Bruchrechnen 3", cut.Find("#set-title").GetAttribute("value")); // Eingabe bleibt stehen
Save(cut);
cut.WaitForAssertion(() => Assert.Equal(3, Api.Updated.Count));
Assert.Equal(new byte[] { 9, 9 }, Api.Updated[2].Request.Version);
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-learning-conflict")));
}
[Fact]
public void Sheets_with_attempts_are_locked_for_items_and_fixed_settings()
{
var detail = Api.AddSet("Schon gelöst", LearningSetStatus.Submitted, itemCount: 2, attempts: 1);
var cut = Open(detail.Set.Id);
Assert.Contains("Es gibt schon Versuche", cut.Find(".fp-learning-locked").TextContent);
Assert.Empty(cut.FindAll(".fp-set-add-item"));
Assert.Empty(cut.FindAll(".fp-set-item-actions"));
Assert.NotEmpty(cut.FindAll(".fp-set-item")); // Aufgaben bleiben lesbar
Assert.True(cut.Find(".fp-set-mode-practice").ClassList.Contains("mud-disabled") || cut.Find(".fp-set-mode-practice").HasAttribute("disabled"));
Assert.True(cut.Find(".fp-set-total input").HasAttribute("disabled"));
Assert.True(cut.Find(".fp-set-shuffle input").HasAttribute("disabled"));
Assert.False(cut.Find("#set-title").HasAttribute("disabled")); // Titel & Co. bleiben änderbar
}
[Fact]
public void Missing_sheets_are_reported()
{
var cut = Open(Guid.NewGuid());
Assert.Contains("Dieses Blatt gibt es nicht (mehr).", cut.Find(".fp-learning-missing").TextContent);
}
// ---------- Aufgaben ----------
[Fact]
public void Items_are_listed_with_type_points_and_solution_and_can_be_reordered_and_deleted()
{
var detail = Api.AddSet("Mit Aufgaben", itemCount: 3);
var cut = Open(detail.Set.Id);
var ids = detail.Items.Select(i => i.Id).ToList();
var rows = cut.FindAll(".fp-set-item");
Assert.Equal(3, rows.Count);
Assert.Contains("Frage 1 von Mit Aufgaben", Flat(rows[0]));
Assert.Contains("Zahl · Leicht · 1 Punkt · Lösung: 12", Flat(rows[0]));
Assert.True(rows[0].QuerySelector(".fp-item-up")!.HasAttribute("disabled"));
Assert.True(rows[2].QuerySelector(".fp-item-down")!.HasAttribute("disabled"));
rows[0].QuerySelector(".fp-item-down")!.Click();
cut.WaitForAssertion(() => Assert.Single(Api.Reordered));
Assert.Equal([ids[1], ids[0], ids[2]], Api.Reordered[0].Order);
cut.WaitForAssertion(() => Assert.Contains("Frage 2", cut.FindAll(".fp-set-item")[0].TextContent));
var click = cut.InvokeAsync(() => cut.FindAll(".fp-set-item")[2].QuerySelector(".fp-item-delete")!.Click());
cut.WaitForAssertion(() => Assert.Contains("Aufgabe löschen?", cut.Markup));
Assert.Contains("Aufgabe 3 wirklich löschen?", cut.Markup);
cut.FindAll("button").First(b => Text(b) == "Löschen").Click();
cut.WaitForAssertion(() => Assert.Equal([(detail.Set.Id, ids[2])], Api.DeletedItems));
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-set-item").Count));
Assert.Contains("Aufgaben (2)", cut.Find(".fp-set-items h2").TextContent);
Assert.True(click.IsCompleted || !click.IsFaulted);
}
[Fact]
public void A_number_item_can_be_added_through_the_dialog_with_default_points_by_difficulty()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
Assert.Contains("Noch keine Aufgaben", cut.Find(".fp-set-no-items").TextContent);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.Contains("Aufgabe hinzufügen", cut.Find(".mud-dialog-title").TextContent));
cut.Find("#item-question").Change("Was ist 7 + 5?");
cut.Find("#item-correct").Change("12");
cut.Find("#item-tolerance").Change("0,5");
cut.Find("#item-hint").Change("Zähle weiter.");
cut.Find("#item-explanation").Change("7 plus 5 ist 12.");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AddedItems));
var (setId, item) = Api.AddedItems[0];
Assert.Equal(detail.Set.Id, setId);
Assert.Equal(("Was ist 7 + 5?", LearningAnswerType.Number, "12", 0.5m, "Zähle weiter.", "7 plus 5 ist 12.", 1, 1),
(item.Question, item.AnswerType, item.CorrectAnswer, item.NumberTolerance, item.Hint, item.Explanation, item.Difficulty, item.Points));
Assert.Null(item.Options);
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-set-item")));
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".mud-dialog")));
Assert.Contains("Aufgaben (1)", cut.Find(".fp-set-items h2").TextContent);
}
[Fact]
public void Item_dialog_validates_inputs_and_shows_messages_without_calling_the_api()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte eine Frage eingeben.", cut.Markup));
Assert.Contains("Die Musterlösung muss eine Zahl sein.", cut.Markup);
Assert.Empty(Api.AddedItems);
cut.Find("#item-question").Change("Frage");
cut.Find("#item-correct").Change("zwölf");
cut.Find("#item-tolerance").Change("viel");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Contains("Bitte eine Zahl eingeben.", cut.Markup));
Assert.Empty(Api.AddedItems);
}
[Fact]
public void Choice_items_need_options_and_a_marked_correct_answer()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
cut.Find(".fp-set-add-item").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
cut.Find("#item-question").Change("Hauptstadt von Frankreich?");
// Antworttyp „Auswahl“ wählen (MudSelect öffnet ein Popover mit den Einträgen)
cut.Find(".fp-item-type").MouseDown();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".mud-list-item")));
cut.FindAll(".mud-list-item").First(i => Text(i) == "Auswahl").Click();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-item-option").Count));
Assert.True(cut.FindAll(".fp-item-option-remove").All(b => b.HasAttribute("disabled"))); // mindestens zwei Optionen
cut.FindAll(".fp-item-option input")[0].Change("Berlin");
cut.FindAll(".fp-item-option input")[1].Change("Paris");
cut.Find(".fp-item-option-add").Click();
cut.FindAll(".fp-item-option input")[2].Change("Rom");
Assert.Equal(3, cut.FindAll(".fp-item-option").Count);
cut.FindAll(".fp-item-correct")[1].Click(); // Paris ist richtig
Assert.Equal("true", cut.FindAll(".fp-item-correct")[1].GetAttribute("aria-checked"));
Assert.Equal("false", cut.FindAll(".fp-item-correct")[0].GetAttribute("aria-checked"));
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.AddedItems));
var item = Api.AddedItems[0].Item;
Assert.Equal((LearningAnswerType.SingleChoice, "1"), (item.AnswerType, item.CorrectAnswer));
Assert.Equal(["Berlin", "Paris", "Rom"], item.Options);
}
[Fact]
public void Existing_items_are_prefilled_and_updated_with_their_version()
{
var detail = Api.AddSet("Ändern", itemCount: 1);
var cut = Open(detail.Set.Id);
cut.Find(".fp-item-edit").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll("#item-question")));
Assert.Equal("Frage 1 von Ändern", cut.Find("#item-question").GetAttribute("value") ?? cut.Find("#item-question").TextContent.Trim());
Assert.Equal("12", cut.Find("#item-correct").GetAttribute("value"));
cut.Find("#item-question").Change("Neue Frage");
cut.Find(".fp-item-save").Click();
cut.WaitForAssertion(() => Assert.Single(Api.UpdatedItems));
var (setId, itemId, request) = Api.UpdatedItems[0];
Assert.Equal((detail.Set.Id, detail.Items[0].Id, "Neue Frage", detail.Items[0].Version), (setId, itemId, request.Question, request.Version));
cut.WaitForAssertion(() => Assert.Contains("Neue Frage", cut.Find(".fp-set-item-question").TextContent));
}
// ---------- Zuweisen ----------
[Fact]
public void Drafts_can_be_assigned_and_unassigned_while_no_attempt_exists()
{
var detail = Api.AddSet("Zuweisen", itemCount: 2);
var cut = Open(detail.Set.Id);
Assert.Contains("Das Blatt ist noch ein Entwurf", cut.Find(".fp-set-assign").TextContent);
Assert.Equal("An Mia zuweisen", Text(cut.Find(".fp-set-assign-button")));
cut.Find(".fp-set-assign-button").Click();
cut.WaitForAssertion(() => Assert.Equal([detail.Set.Id], Api.Assigned));
cut.WaitForAssertion(() => Assert.Equal("Zugewiesen", Text(cut.Find(".fp-learning-status"))));
Assert.Contains("Noch kein Versuch.", cut.Find(".fp-set-assign").TextContent);
cut.Find(".fp-set-unassign-button").Click();
cut.WaitForAssertion(() => Assert.Equal([detail.Set.Id], Api.Unassigned));
cut.WaitForAssertion(() => Assert.Equal("Entwurf", Text(cut.Find(".fp-learning-status"))));
}
[Fact]
public void Assigning_a_sheet_the_server_rejects_shows_the_reason()
{
var detail = Api.AddSet("Leer", itemCount: 0);
var cut = Open(detail.Set.Id);
Api.Offline = true;
cut.Find(".fp-set-assign-button").Click();
cut.WaitForAssertion(() => Assert.Contains("Keine Verbindung zum Server", cut.Find(".fp-dialog-error").TextContent));
}
[Fact]
public void Attempts_are_listed_with_links_to_the_review()
{
var detail = Api.AddSet("Mit Versuchen", LearningSetStatus.Submitted, attempts: 2);
var submitted = new LearningAttemptSummaryDto(Guid.NewGuid(), 1, LearningAttemptStatus.Reviewed, FakeLearningApi.Now, FakeLearningApi.Now, false, 3, 4, 3);
var waiting = new LearningAttemptSummaryDto(Guid.NewGuid(), 2, LearningAttemptStatus.Submitted, FakeLearningApi.Now, FakeLearningApi.Now, true, null, null, null);
var running = new LearningAttemptSummaryDto(Guid.NewGuid(), 3, LearningAttemptStatus.InProgress, FakeLearningApi.Now, null, false, null, null, null);
Api.Details[detail.Set.Id] = detail with { Attempts = [submitted, waiting, running] };
var cut = Open(detail.Set.Id);
var entries = cut.FindAll(".fp-set-attempts-list li");
Assert.Equal(3, entries.Count);
Assert.Contains("geprüft: 3 von 4 Punkten, Note 3", entries[0].TextContent);
Assert.Equal("Ansehen", Text(entries[0].QuerySelector(".fp-set-review-link")!));
Assert.Contains("abgegeben (Zeit abgelaufen), wartet auf Prüfung", entries[1].TextContent);
Assert.Equal("Prüfen", Text(entries[1].QuerySelector(".fp-set-review-link")!));
Assert.Equal($"lernen/pruefen/{waiting.Id}", entries[1].QuerySelector(".fp-set-review-link")!.GetAttribute("href"));
Assert.Contains("läuft", entries[2].TextContent);
Assert.Null(entries[2].QuerySelector(".fp-set-review-link"));
}
}
@@ -179,6 +179,8 @@ internal sealed class FakeLiveConnection : ILiveConnection
public event Func<Task>? TaskPointsChanged;
public event Func<FamilyPlanner.Shared.Learning.LearningChangedDto, Task>? LearningChanged;
public event Func<Guid, Task>? CalendarChanged;
public event Func<ChatMessageDto, Task>? ChatMessageReceived;
@@ -210,6 +212,8 @@ internal sealed class FakeLiveConnection : ILiveConnection
public Task RaiseChatTyping(Guid channelId, Guid userId) => ChatTyping?.Invoke(channelId, userId) ?? Task.CompletedTask;
public Task RaiseLearningChanged(FamilyPlanner.Shared.Learning.LearningChangedDto change) => LearningChanged?.Invoke(change) ?? Task.CompletedTask;
public Task RaiseCalendarChanged(Guid calendarId) => CalendarChanged?.Invoke(calendarId) ?? Task.CompletedTask;
public Task EnsureStartedAsync() => Task.CompletedTask;