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