test: cover learning rules, grading, grade scales, validators and child DTO visibility

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 13:52:38 +02:00
1 parent cc129eb1e5
commit 42406598c7
6 files changed
+1018 -1

No files matched your search

@@ -413,7 +413,7 @@ public static class LearningValidation
validator.RuleFor(r => r)
.Must(r => LearningItemRules.CheckSolution(ToItem(r)) is null)
.When(r => LearningItemRules.IsSupported(r.AnswerType))
.WithName(nameof(ILearningItemFields.CorrectAnswer))
.OverridePropertyName(nameof(ILearningItemFields.CorrectAnswer))
.WithMessage(r => LearningItemRules.CheckSolution(ToItem(r)) ?? string.Empty);
}
@@ -0,0 +1,104 @@
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Notenschlüssel (Standard: Schulnoten 1–6 nach Prozent) und Prüfung des Schlüssels.</summary>
public sealed class LearningGradesTests
{
[Theory]
[InlineData(100, 100, 1)]
[InlineData(92, 100, 1)]
[InlineData(91, 100, 2)]
[InlineData(81, 100, 2)]
[InlineData(80, 100, 3)]
[InlineData(67, 100, 3)]
[InlineData(66, 100, 4)]
[InlineData(50, 100, 4)]
[InlineData(49, 100, 5)]
[InlineData(30, 100, 5)]
[InlineData(29, 100, 6)]
[InlineData(0, 100, 6)]
public void Default_scale_maps_percent_to_school_grades(int earned, int total, int expected) =>
Assert.Equal(expected, LearningGrades.GradeFor(earned, total));
[Theory]
[InlineData(23, 25, 1)] // 92 % exakt (ohne Gleitkomma-Rundung)
[InlineData(22, 25, 2)] // 88 %
[InlineData(3, 4, 3)] // 75 %
[InlineData(2, 4, 4)] // 50 % exakt
[InlineData(1, 3, 5)] // 33 %
[InlineData(2, 3, 4)] // 66,7 % liegt knapp unter 67 %
[InlineData(0, 3, 6)]
[InlineData(1, 1, 1)]
public void Grades_use_exact_integer_comparison(int earned, int total, int expected) =>
Assert.Equal(expected, LearningGrades.GradeFor(earned, total));
[Fact]
public void A_custom_scale_overrides_the_default()
{
Assert.True(LearningGrades.TryParse("90;80;70;60;50", out var thresholds, out var error));
Assert.Null(error);
Assert.Equal(1, LearningGrades.GradeFor(90, 100, thresholds));
Assert.Equal(2, LearningGrades.GradeFor(89, 100, thresholds));
Assert.Equal(5, LearningGrades.GradeFor(50, 100, thresholds));
Assert.Equal(6, LearningGrades.GradeFor(49, 100, thresholds));
Assert.Equal(1, LearningGrades.GradeFor(100, 100, [100, 80, 60, 40, 20])); // nur volle Punktzahl ergibt 1
Assert.Equal(2, LearningGrades.GradeFor(99, 100, [100, 80, 60, 40, 20]));
}
[Fact]
public void Zero_total_is_the_worst_grade()
{
Assert.Equal(6, LearningGrades.GradeFor(0, 0));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Empty_scale_means_default(string? text)
{
Assert.True(LearningGrades.TryParse(text, out var thresholds, out var error));
Assert.Null(error);
Assert.Equal(LearningGrades.DefaultThresholds, thresholds);
}
[Theory]
[InlineData("92;81;67;50;30", true)]
[InlineData("92; 81; 67; 50; 30", true)]
[InlineData("92,81,67,50,30", true)]
[InlineData("100;80;60;40;20", true)]
[InlineData("5;4;3;2;1", true)]
[InlineData("92;81;67;50", false)] // zu wenige
[InlineData("92;81;67;50;30;10", false)] // zu viele
[InlineData("92;81;67;50;abc", false)]
[InlineData("92;81;81;50;30", false)] // nicht streng absteigend
[InlineData("30;50;67;81;92", false)] // aufsteigend
[InlineData("101;81;67;50;30", false)] // über 100
[InlineData("92;81;67;50;0", false)] // 0 ist nicht sinnvoll
[InlineData("-5;81;67;50;30", false)]
public void Scales_are_validated(string text, bool valid)
{
Assert.Equal(valid, LearningGrades.TryParse(text, out _, out var error));
Assert.Equal(valid, error is null);
}
[Fact]
public void Format_stores_only_custom_scales()
{
Assert.Null(LearningGrades.Format(LearningGrades.DefaultThresholds));
Assert.Equal("90;80;70;60;50", LearningGrades.Format([90, 80, 70, 60, 50]));
}
[Theory]
[InlineData(1, "sehr gut")]
[InlineData(2, "gut")]
[InlineData(3, "befriedigend")]
[InlineData(4, "ausreichend")]
[InlineData(5, "mangelhaft")]
[InlineData(6, "ungenügend")]
[InlineData(0, "")]
[InlineData(7, "")]
public void Grades_have_german_labels(int grade, string label) => Assert.Equal(label, LearningGrades.Label(grade));
}
@@ -0,0 +1,210 @@
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Zahlenformate und automatische Bewertung (ADR-0021 Punkt 3): Komma/Punkt, Tausendertrenner, Toleranz, Auswahl, Freitext.</summary>
public sealed class LearningGradingTests
{
private static LearningItem Number(string correct, decimal? tolerance = null, int points = 2) =>
new() { AnswerType = LearningAnswerType.Number, CorrectAnswer = correct, NumberTolerance = tolerance, Points = points };
private static LearningItem Text(string correct, int points = 1) =>
new() { AnswerType = LearningAnswerType.Text, CorrectAnswer = correct, Points = points };
private static LearningItem Choice(string correctIndex, params string[] options) =>
new() { AnswerType = LearningAnswerType.SingleChoice, CorrectAnswer = correctIndex, Options = [.. options], Points = 1 };
// ---------- Zahlen lesen ----------
public static TheoryData<string, decimal[]> NumberFormats => new()
{
{ "12", [12m] },
{ " 12 ", [12m] },
{ "-12", [-12m] },
{ "+12", [12m] },
{ "−12", [-12m] }, // typografisches Minus
{ "12,5", [12.5m] },
{ "12.5", [12.5m] },
{ "0,125", [0.125m] },
{ "0.125", [0.125m] },
{ ",5", [0.5m] },
{ "5,", [5m] },
{ "1.234,5", [1234.5m] }, // deutsch
{ "1,234.5", [1234.5m] }, // englisch
{ "1.234.567", [1234567m] },
{ "1,234,567", [1234567m] },
{ "1.234.567,89", [1234567.89m] },
{ "1 234,5", [1234.5m] }, // Leerzeichen als Tausendertrenner
{ "1 234", [1234m] }, // geschütztes Leerzeichen
{ "1.234", [1.234m, 1234m] }, // mehrdeutig: Dezimal oder Tausender → beide Werte
{ "1,234", [1.234m, 1234m] },
{ "12,345", [12.345m, 12345m] },
{ "0,500", [0.5m] }, // führende Null: nur Dezimal
{ "1234,567", [1234.567m] }, // 4 Ziffern vor dem Komma: nur Dezimal
{ "-1.250", [-1.25m, -1250m] },
};
[Theory]
[MemberData(nameof(NumberFormats))]
public void Numbers_are_read_independently_of_the_device_culture(string input, decimal[] expected)
{
using var _ = new CultureScope("de-DE");
Assert.Equal(expected, LearningNumbers.Parse(input));
using var __ = new CultureScope("en-US");
Assert.Equal(expected, LearningNumbers.Parse(input));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("abc")]
[InlineData("1/2")] // Brüche werden bewusst nicht erkannt
[InlineData("5 cm")]
[InlineData("1.2.3")]
[InlineData("1,2,3")]
[InlineData("1,5.3")]
[InlineData("1.234,56,7")]
[InlineData("12e3")]
[InlineData("--5")]
[InlineData("-")]
[InlineData(",")]
[InlineData("12345678901234567890123456789")] // länger als 28 Zeichen
public void Non_numbers_are_not_recognised(string? input) => Assert.Empty(LearningNumbers.Parse(input));
[Fact]
public void ParseFirst_prefers_the_decimal_reading()
{
Assert.Equal(1.234m, LearningNumbers.ParseFirst("1.234"));
Assert.Null(LearningNumbers.ParseFirst("x"));
}
// ---------- Zahlenaufgaben ----------
[Theory]
[InlineData("12,5", "12,5", true)]
[InlineData("12,5", "12.5", true)]
[InlineData("12,5", "12,50", true)]
[InlineData("12,5", " 12,5 ", true)]
[InlineData("12,5", "12", false)]
[InlineData("1234", "1.234", true)] // Tausenderpunkt
[InlineData("1234", "1234", true)]
[InlineData("1234", "1 234", true)]
[InlineData("1,234", "1.234", true)] // mehrdeutige Eingabe passt auf die Lösung
[InlineData("-3", "−3", true)]
[InlineData("0", "0,0", true)]
[InlineData("7", "sieben", false)]
[InlineData("7", "7 cm", false)]
public void Numbers_are_graded_exactly(string correct, string answer, bool expectedCorrect)
{
var result = LearningGrading.Grade(Number(correct, points: 3), answer);
Assert.Equal(expectedCorrect ? LearningVerdict.Correct : LearningVerdict.Wrong, result.Verdict);
Assert.Equal(expectedCorrect ? 3 : 0, result.AwardedPoints);
}
[Theory]
[InlineData("3,14", "0,01", "3,15", true)]
[InlineData("3,14", "0,01", "3,13", true)]
[InlineData("3,14", "0,01", "3,16", false)]
[InlineData("100", "5", "104", true)]
[InlineData("100", "5", "94", false)]
[InlineData("100", "0", "100,1", false)]
public void Tolerance_is_absolute_and_inclusive(string correct, string tolerance, string answer, bool expectedCorrect)
{
var item = Number(correct, decimal.Parse(tolerance, System.Globalization.CultureInfo.GetCultureInfo("de-DE")));
Assert.Equal(expectedCorrect, LearningGrading.Grade(item, answer).Verdict == LearningVerdict.Correct);
}
[Fact]
public void Empty_and_unreadable_answers_are_wrong_never_open()
{
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("1"), null).Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Number("1"), " ").Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Text("Hund"), "").Verdict);
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Choice("0", "a", "b"), null).Verdict);
Assert.Equal("Keine Zahl erkannt.", LearningGrading.Grade(Number("1"), "x").Reason);
}
// ---------- Auswahl ----------
[Theory]
[InlineData("1", "1", true)]
[InlineData("1", " 1 ", true)]
[InlineData("1", "0", false)]
[InlineData("1", "2", false)]
[InlineData("1", "abc", false)]
[InlineData("1", "-1", false)]
[InlineData("1", "7", false)] // außerhalb der Optionen
public void Choice_compares_option_indices(string correct, string answer, bool expectedCorrect)
{
var result = LearningGrading.Grade(Choice(correct, "a", "b", "c"), answer);
Assert.Equal(expectedCorrect ? LearningVerdict.Correct : LearningVerdict.Wrong, result.Verdict);
}
[Fact]
public void Choice_with_a_correct_index_outside_the_options_is_never_correct()
{
Assert.Equal(LearningVerdict.Wrong, LearningGrading.Grade(Choice("5", "a", "b"), "5").Verdict);
}
// ---------- Freitext ----------
[Theory]
[InlineData("Hund", "hund")]
[InlineData("Hund", " HUND ")]
[InlineData("Hund", "Hund.")]
[InlineData("Hund", "Hund!")]
[InlineData("die Katze", "Die Katze")]
[InlineData("die Katze", "die\tKatze")]
[InlineData("Hund|Wauwau", "wauwau")] // Varianten
[InlineData("Straße", "STRAßE")]
[InlineData("Ärger", "ärger")]
public void Exact_text_matches_after_normalisation_are_correct(string correct, string answer)
{
var result = LearningGrading.Grade(Text(correct, points: 4), answer);
Assert.Equal((LearningVerdict.Correct, 4), (result.Verdict, result.AwardedPoints));
}
[Theory]
[InlineData("Hund", "Hunde")]
[InlineData("Hund", "Hnd")]
[InlineData("Hund", "der Hund")]
[InlineData("Hund|Wauwau", "Katze")]
public void Other_text_is_left_open_for_the_parents_never_wrong(string correct, string answer)
{
var result = LearningGrading.Grade(Text(correct), answer);
Assert.Equal((LearningVerdict.Open, 0), (result.Verdict, result.AwardedPoints));
}
[Fact]
public void Paper_answers_are_open_until_phase_10c()
{
var item = new LearningItem { AnswerType = LearningAnswerType.Paper, Points = 2 };
Assert.Equal(LearningVerdict.Open, LearningGrading.Grade(item, "irgendwas").Verdict);
Assert.False(LearningItemRules.IsSupported(LearningAnswerType.Paper));
}
[Fact]
public void Normalisation_handles_unicode_forms_and_whitespace()
{
Assert.Equal("ärger", LearningGrading.NormalizeText("Ärger")); // zerlegtes Ä
Assert.Equal(string.Empty, LearningGrading.NormalizeText(null));
Assert.Equal(string.Empty, LearningGrading.NormalizeText(" \t "));
Assert.Equal("a b", LearningGrading.NormalizeText(" a \n b "));
}
private sealed class CultureScope : IDisposable
{
private readonly System.Globalization.CultureInfo _previous = System.Globalization.CultureInfo.CurrentCulture;
public CultureScope(string name) => System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(name);
public void Dispose() => System.Globalization.CultureInfo.CurrentCulture = _previous;
}
}
@@ -0,0 +1,198 @@
using System.Text.Json;
using FamilyPlanner.Api.Features.Learning;
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>
/// Kinder-DTOs enthalten Musterlösung, Hinweis und Erklärung nur, wenn <see cref="LearningRules.VisibilityFor"/> es erlaubt (ADR-0021 Punkt 5).
/// Geprüft an den serialisierten Antworten – so, wie sie den Server verlassen.
/// </summary>
public sealed class LearningMapperTests
{
private const string Solution = "LOESUNG-4711";
private const string Hint = "HINWEIS-GEHEIM";
private const string Explanation = "ERKLAERUNG-GEHEIM";
private const string ParentReason = "BEGRUENDUNG-DER-ELTERN";
private static readonly DateTimeOffset Now = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
private static (LearningSet Set, LearningAttempt Attempt) Build(LearningMode mode, bool showSolutions, LearningAttemptStatus status)
{
var set = new LearningSet
{
Title = "Test",
Mode = mode,
ShowSolutionsAfterSubmit = showSolutions,
Status = LearningSetStatus.InProgress,
TargetUserId = Guid.NewGuid(),
TotalPoints = 10,
};
var item1 = new LearningItem { SetId = set.Id, SortOrder = 0, Question = "Frage 1", AnswerType = LearningAnswerType.Number, CorrectAnswer = Solution, Hint = Hint, Explanation = Explanation, Points = 2 };
var item2 = new LearningItem { SetId = set.Id, SortOrder = 1, Question = "Frage 2", AnswerType = LearningAnswerType.SingleChoice, CorrectAnswer = "1", Options = ["a", "b"], Hint = Hint, Explanation = Explanation, Points = 1 };
var item3 = new LearningItem { SetId = set.Id, SortOrder = 2, Question = "Frage 3", AnswerType = LearningAnswerType.Text, CorrectAnswer = Solution, Points = 1 };
set.Items.AddRange([item1, item2, item3]);
var attempt = new LearningAttempt
{
SetId = set.Id,
UserId = set.TargetUserId,
Number = 1,
Status = status,
StartedAt = Now,
ItemOrder = LearningRules.SerializeOrder(set.Items.Select(i => i.Id)),
};
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item1.Id, Value = "7", AnsweredAt = Now, Verdict = LearningVerdict.Wrong, Reason = null });
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item2.Id, Value = "1", AnsweredAt = Now, Verdict = LearningVerdict.Correct, AwardedPoints = 1 });
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = item3.Id, Value = "Hallo", AnsweredAt = Now, Verdict = LearningVerdict.Open, Grader = LearningGrader.Auto });
set.Attempts.Add(attempt);
return (set, attempt);
}
private static string Json(LearningSet set, LearningAttempt attempt) =>
JsonSerializer.Serialize(LearningMapper.ToAttemptDto(attempt, set, Now), Web);
public static TheoryData<LearningMode, bool, LearningAttemptStatus, bool, bool> Cases => new()
{
// Modus, Lösungen nach Abgabe, Status, Hinweis sichtbar, Lösung sichtbar
{ LearningMode.Practice, true, LearningAttemptStatus.InProgress, true, false },
{ LearningMode.Practice, false, LearningAttemptStatus.InProgress, true, false },
{ LearningMode.Practice, true, LearningAttemptStatus.Submitted, true, true },
{ LearningMode.Practice, false, LearningAttemptStatus.Submitted, true, false },
{ LearningMode.Practice, true, LearningAttemptStatus.Reviewed, true, true },
{ LearningMode.Exam, true, LearningAttemptStatus.InProgress, false, false },
{ LearningMode.Exam, false, LearningAttemptStatus.InProgress, false, false },
{ LearningMode.Exam, true, LearningAttemptStatus.Submitted, false, true },
{ LearningMode.Exam, false, LearningAttemptStatus.Submitted, false, false },
{ LearningMode.Exam, false, LearningAttemptStatus.Reviewed, false, false },
};
[Theory]
[MemberData(nameof(Cases))]
public void Child_payload_contains_hints_and_solutions_only_when_the_rules_allow(
LearningMode mode, bool showSolutions, LearningAttemptStatus status, bool hintVisible, bool solutionVisible)
{
var (set, attempt) = Build(mode, showSolutions, status);
var json = Json(set, attempt);
Assert.Equal(hintVisible, json.Contains(Hint, StringComparison.Ordinal));
Assert.Equal(solutionVisible, json.Contains(Solution, StringComparison.Ordinal));
Assert.Equal(solutionVisible, json.Contains(Explanation, StringComparison.Ordinal));
}
[Fact]
public void Exam_in_progress_has_no_feedback_at_all()
{
var (set, attempt) = Build(LearningMode.Exam, true, LearningAttemptStatus.InProgress);
var dto = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.All(dto.Items, i => Assert.Null(i.Feedback));
Assert.All(dto.Items, i => Assert.Null(i.Hint));
Assert.Null(dto.Result);
Assert.Equal(["7", "1", "Hallo"], dto.Items.Select(i => i.Answer)); // die eigenen Antworten bleiben sichtbar
}
[Fact]
public void Practice_in_progress_shows_the_verdict_of_answered_items_but_never_the_solution()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers.Add(new LearningAnswer { AttemptId = attempt.Id, ItemId = Guid.NewGuid(), Value = null, Verdict = LearningVerdict.Open }); // Zeile zu unbekannter Aufgabe wird ignoriert
var dto = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.Equal([LearningVerdict.Wrong, LearningVerdict.Correct, LearningVerdict.Open], dto.Items.Select(i => i.Feedback!.Verdict));
Assert.All(dto.Items, i => Assert.Null(i.Feedback!.CorrectAnswer));
Assert.All(dto.Items, i => Assert.Null(i.Feedback!.Explanation));
}
[Fact]
public void Unanswered_items_have_no_feedback_in_practice_until_submitted()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers.Clear();
Assert.All(LearningMapper.ToAttemptDto(attempt, set, Now).Items, i => Assert.Null(i.Feedback));
attempt.Status = LearningAttemptStatus.Submitted;
var submitted = LearningMapper.ToAttemptDto(attempt, set, Now);
Assert.All(submitted.Items, i => Assert.Equal(LearningVerdict.Open, i.Feedback!.Verdict));
Assert.All(submitted.Items, i => Assert.Equal(set.Items.Single(x => x.Id == i.ItemId).CorrectAnswer, i.Feedback!.CorrectAnswer)); // Lösungen nach Abgabe
}
[Fact]
public void Parent_reasons_reach_the_child_only_after_submission()
{
var (set, attempt) = Build(LearningMode.Practice, true, LearningAttemptStatus.InProgress);
attempt.Answers[0].Reason = ParentReason;
Assert.DoesNotContain(ParentReason, Json(set, attempt));
attempt.Status = LearningAttemptStatus.Reviewed;
Assert.Contains(ParentReason, Json(set, attempt));
}
[Fact]
public void Child_dtos_have_no_properties_for_prompts_providers_or_solutions_at_the_set_level()
{
var names = typeof(MyLearningSetDto).GetProperties().Select(p => p.Name)
.Concat(typeof(LearningAttemptDto).GetProperties().Select(p => p.Name))
.Concat(typeof(MyLearningResultDto).GetProperties().Select(p => p.Name))
.Concat(typeof(LearningAttemptItemDto).GetProperties().Select(p => p.Name))
.ToList();
Assert.DoesNotContain(names, n => n.Contains("Prompt", StringComparison.Ordinal) || n.Contains("Provider", StringComparison.Ordinal) || n.Contains("Model", StringComparison.Ordinal));
Assert.DoesNotContain("CorrectAnswer", names);
Assert.DoesNotContain("Explanation", names);
}
[Fact]
public void Result_shows_the_grade_only_for_confirmed_exams()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
var items = set.Items.ToDictionary(i => i.Id);
var submitted = LearningMapper.ToResult(attempt, items, set);
Assert.Equal((false, null, 1, 4), (submitted.IsFinal, submitted.Grade, submitted.OpenCount, submitted.TotalItemPoints));
Assert.Equal(1, submitted.EarnedPoints); // nur die richtige Auswahl; die Zahl ist falsch, der Freitext offen
attempt.Status = LearningAttemptStatus.Reviewed;
attempt.EarnedPoints = 3;
attempt.TotalItemPoints = 4;
attempt.Grade = 3;
var reviewed = LearningMapper.ToResult(attempt, items, set);
Assert.Equal((true, 3, 3, 4), (reviewed.IsFinal, reviewed.Grade, reviewed.EarnedPoints, reviewed.TotalItemPoints));
set.Mode = LearningMode.Practice;
Assert.Null(LearningMapper.ToResult(attempt, items, set).Grade); // Übung: keine Note
}
[Fact]
public void Parent_review_shows_the_solution_and_a_grade_preview_only_when_nothing_is_open()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
var open = LearningMapper.ToReviewDto(attempt, set, "Mia", 0);
Assert.Equal(Solution, open.Answers[0].CorrectAnswer);
Assert.Equal((1, 4, 1, (int?)null), (open.EarnedPoints, open.TotalItemPoints, open.OpenCount, open.Grade));
attempt.Answers[2].Verdict = LearningVerdict.Correct;
attempt.Answers[2].AwardedPoints = 1;
var complete = LearningMapper.ToReviewDto(attempt, set, "Mia", 0);
Assert.Equal((2, 0, LearningGrades.GradeFor(2, 4)), (complete.EarnedPoints, complete.OpenCount, complete.Grade!.Value));
}
[Fact]
public void Custom_grade_scales_apply_to_the_preview()
{
var (set, attempt) = Build(LearningMode.Exam, false, LearningAttemptStatus.Submitted);
attempt.Answers[2].Verdict = LearningVerdict.Correct;
attempt.Answers[2].AwardedPoints = 1;
set.GradeThresholds = "50;40;30;20;10";
Assert.Equal(1, LearningMapper.ToReviewDto(attempt, set, "Mia", 0).Grade); // 2 von 4 = 50 % → 1 nach strengem Schlüssel
}
}
@@ -0,0 +1,352 @@
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Authorization;
using FamilyPlanner.Domain.Learning;
namespace FamilyPlanner.UnitTests.Learning;
/// <summary>Zeitlimit, Versuche, Status, Sichtbarkeit (Prüfungsregeln), Reihenfolge, Punkte, Noten (ADR-0021).</summary>
public sealed class LearningRulesTests
{
private static readonly DateTimeOffset Start = new(2026, 10, 12, 8, 0, 0, TimeSpan.Zero);
// ---------- Zeitlimit ----------
[Fact]
public void Deadline_is_start_plus_limit_or_none()
{
Assert.Equal(Start.AddMinutes(20), LearningRules.DeadlineFor(Start, 20));
Assert.Null(LearningRules.DeadlineFor(Start, null));
Assert.Null(LearningRules.DeadlineFor(Start, 0));
}
[Fact]
public void Saves_are_accepted_until_the_deadline_plus_a_small_fixed_tolerance()
{
var deadline = Start.AddMinutes(10);
Assert.True(LearningRules.AcceptsSaves(null, Start.AddYears(1)));
Assert.True(LearningRules.AcceptsSaves(deadline, Start));
Assert.True(LearningRules.AcceptsSaves(deadline, deadline));
Assert.True(LearningRules.AcceptsSaves(deadline, deadline + LearningRules.DeadlineTolerance));
Assert.False(LearningRules.AcceptsSaves(deadline, deadline + LearningRules.DeadlineTolerance + TimeSpan.FromMilliseconds(1)));
Assert.InRange(LearningRules.DeadlineTolerance, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); // „klein“
}
[Fact]
public void Auto_submit_is_due_exactly_when_saves_are_no_longer_accepted()
{
var deadline = Start.AddMinutes(10);
Assert.False(LearningRules.MustAutoSubmit(null, Start.AddYears(1)));
Assert.False(LearningRules.MustAutoSubmit(deadline, deadline + LearningRules.DeadlineTolerance));
Assert.True(LearningRules.MustAutoSubmit(deadline, deadline + LearningRules.DeadlineTolerance + TimeSpan.FromTicks(1)));
}
[Fact]
public void Submission_time_is_never_after_the_deadline()
{
var deadline = Start.AddMinutes(10);
Assert.Equal(Start.AddMinutes(3), LearningRules.SubmittedAtFor(deadline, Start.AddMinutes(3)));
Assert.Equal(deadline, LearningRules.SubmittedAtFor(deadline, deadline.AddHours(5)));
Assert.Equal(Start, LearningRules.SubmittedAtFor(null, Start));
}
[Fact]
public void Remaining_time_counts_down_and_never_goes_negative()
{
var deadline = Start.AddMinutes(10);
Assert.Equal(TimeSpan.FromMinutes(7), LearningRules.Remaining(deadline, Start.AddMinutes(3)));
Assert.Equal(TimeSpan.Zero, LearningRules.Remaining(deadline, deadline.AddMinutes(1)));
Assert.Null(LearningRules.Remaining(null, Start));
}
// ---------- Status und Versuche ----------
private static readonly LearningAttemptStatus InProgress = LearningAttemptStatus.InProgress;
private static readonly LearningAttemptStatus Submitted = LearningAttemptStatus.Submitted;
private static readonly LearningAttemptStatus Reviewed = LearningAttemptStatus.Reviewed;
public static TheoryData<LearningSetStatus, LearningAttemptStatus[], LearningSetStatus> StatusCases => new()
{
{ LearningSetStatus.Draft, [], LearningSetStatus.Draft },
{ LearningSetStatus.Draft, [LearningAttemptStatus.InProgress], LearningSetStatus.Draft }, // Entwürfe bleiben Entwürfe
{ LearningSetStatus.Assigned, [], LearningSetStatus.Assigned },
{ LearningSetStatus.Assigned, [LearningAttemptStatus.InProgress], LearningSetStatus.InProgress },
{ LearningSetStatus.InProgress, [LearningAttemptStatus.Submitted], LearningSetStatus.Submitted },
{ LearningSetStatus.Submitted, [LearningAttemptStatus.Reviewed], LearningSetStatus.Reviewed },
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.InProgress], LearningSetStatus.InProgress }, // Wiederholung läuft
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.Submitted], LearningSetStatus.Submitted }, // erneut geöffnet
{ LearningSetStatus.Submitted, [LearningAttemptStatus.Submitted, LearningAttemptStatus.InProgress], LearningSetStatus.InProgress },
{ LearningSetStatus.Reviewed, [LearningAttemptStatus.Reviewed, LearningAttemptStatus.Reviewed], LearningSetStatus.Reviewed },
{ LearningSetStatus.Reviewed, [], LearningSetStatus.Assigned },
};
[Theory]
[MemberData(nameof(StatusCases))]
public void Set_status_is_derived_from_the_attempts(LearningSetStatus current, LearningAttemptStatus[] attempts, LearningSetStatus expected) =>
Assert.Equal(expected, LearningRules.DeriveStatus(current, attempts));
[Fact]
public void A_new_attempt_needs_an_assigned_sheet_and_respects_the_attempt_limit()
{
Assert.Equal(AttemptStart.NotAssigned, LearningRules.CanStartAttempt(LearningSetStatus.Draft, 0, []));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Assigned, 1, []));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 1, [Reviewed]));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Submitted, 1, [Submitted]));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 2, [Reviewed]));
Assert.Equal(AttemptStart.NoAttemptsLeft, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 2, [Reviewed, Submitted]));
Assert.Equal(AttemptStart.Ok, LearningRules.CanStartAttempt(LearningSetStatus.Reviewed, 0, Enumerable.Repeat(Reviewed, 50).ToList())); // 0 = unbegrenzt
}
[Fact]
public void A_running_attempt_is_resumed_not_counted_twice()
{
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 1, [InProgress]));
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 2, [Reviewed, InProgress]));
Assert.Equal(AttemptStart.Running, LearningRules.CanStartAttempt(LearningSetStatus.InProgress, 0, [InProgress]));
}
[Theory]
[InlineData(0, 5, int.MaxValue)]
[InlineData(1, 0, 1)]
[InlineData(3, 1, 2)]
[InlineData(3, 3, 0)]
[InlineData(3, 9, 0)]
public void Attempts_left(int max, int used, int expected) => Assert.Equal(expected, LearningRules.AttemptsLeft(max, used));
[Fact]
public void Items_are_editable_only_before_the_first_attempt()
{
Assert.True(LearningRules.CanEditItems(0));
Assert.False(LearningRules.CanEditItems(1));
}
[Fact]
public void Only_drafts_with_valid_items_can_be_assigned()
{
var valid = new LearningItem { AnswerType = LearningAnswerType.Number, CorrectAnswer = "4", SortOrder = 0 };
var broken = new LearningItem { AnswerType = LearningAnswerType.Number, CorrectAnswer = "vier", SortOrder = 1 };
Assert.Null(LearningRules.CheckAssignable(LearningSetStatus.Draft, [valid]));
Assert.Equal("Das Blatt ist bereits zugewiesen.", LearningRules.CheckAssignable(LearningSetStatus.Assigned, [valid]));
Assert.Equal("Das Blatt braucht mindestens eine Aufgabe.", LearningRules.CheckAssignable(LearningSetStatus.Draft, []));
Assert.StartsWith("Aufgabe 2:", LearningRules.CheckAssignable(LearningSetStatus.Draft, [valid, broken]));
}
// ---------- Rechte ----------
[Theory]
[InlineData(Roles.Child, true, LearningSetStatus.Assigned, true)]
[InlineData(Roles.Child, true, LearningSetStatus.InProgress, true)]
[InlineData(Roles.Child, true, LearningSetStatus.Reviewed, true)]
[InlineData(Roles.Child, true, LearningSetStatus.Draft, false)] // Entwürfe sind unsichtbar
[InlineData(Roles.Child, false, LearningSetStatus.Assigned, false)] // fremdes Kind
[InlineData(Roles.Family, false, LearningSetStatus.Assigned, false)]// auch Eltern lösen nicht für andere
[InlineData(Roles.Guest, true, LearningSetStatus.Assigned, false)] // Gäste nie
public void Solving_is_limited_to_the_target_child_and_assigned_sheets(string role, bool isTarget, LearningSetStatus status, bool expected)
{
var me = Guid.NewGuid();
var actor = LearningActor.From(me, [role]);
Assert.Equal(expected, LearningRules.CanSolve(actor, isTarget ? me : Guid.NewGuid(), status));
}
[Theory]
[InlineData(Roles.Admin, true, AccessLevel.Full)]
[InlineData(Roles.Family, true, AccessLevel.Full)]
[InlineData(Roles.Child, false, AccessLevel.Restricted)]
[InlineData(Roles.Guest, false, AccessLevel.None)]
public void Actor_reflects_the_permission_matrix(string role, bool canManage, AccessLevel solve)
{
var actor = LearningActor.From(Guid.NewGuid(), [role]);
Assert.Equal((canManage, solve), (actor.CanManage, actor.Solve));
}
// ---------- Sichtbarkeit / Prüfungsregeln ----------
public static TheoryData<LearningMode, LearningAttemptStatus, bool, bool, bool, bool> VisibilityCases => new()
{
// Modus, Status, Lösungen nach Abgabe, Hinweis, Bewertung, Lösung
{ LearningMode.Practice, LearningAttemptStatus.InProgress, true, true, true, false }, // Übung: Sofort-Rückmeldung + Hinweise, Lösung erst nach Abgabe
{ LearningMode.Practice, LearningAttemptStatus.InProgress, false, true, true, false },
{ LearningMode.Practice, LearningAttemptStatus.Submitted, true, true, true, true },
{ LearningMode.Practice, LearningAttemptStatus.Submitted, false, true, true, false },
{ LearningMode.Practice, LearningAttemptStatus.Reviewed, true, true, true, true },
{ LearningMode.Exam, LearningAttemptStatus.InProgress, true, false, false, false }, // Prüfung: nichts bis zur Abgabe, auch wenn „Lösungen zeigen“ an ist
{ LearningMode.Exam, LearningAttemptStatus.InProgress, false, false, false, false },
{ LearningMode.Exam, LearningAttemptStatus.Submitted, true, false, true, true }, // danach Bewertung, nie Hinweise
{ LearningMode.Exam, LearningAttemptStatus.Submitted, false, false, true, false },
{ LearningMode.Exam, LearningAttemptStatus.Reviewed, false, false, true, false },
{ LearningMode.Exam, LearningAttemptStatus.Reviewed, true, false, true, true },
};
[Theory]
[MemberData(nameof(VisibilityCases))]
public void Visibility_follows_mode_status_and_setting(
LearningMode mode, LearningAttemptStatus status, bool showSolutions, bool hint, bool verdict, bool solution)
{
var visibility = LearningRules.VisibilityFor(mode, status, showSolutions);
Assert.Equal((hint, verdict, solution), (visibility.ShowHint, visibility.ShowVerdict, visibility.ShowSolution));
}
[Fact]
public void A_solution_is_never_visible_while_the_attempt_is_running()
{
foreach (var mode in Enum.GetValues<LearningMode>())
{
foreach (var setting in new[] { true, false })
{
Assert.False(LearningRules.VisibilityFor(mode, LearningAttemptStatus.InProgress, setting).ShowSolution);
}
}
}
// ---------- Reihenfolge ----------
[Fact]
public void Unshuffled_order_keeps_the_sort_order()
{
var ids = Enumerable.Range(0, 8).Select(_ => Guid.NewGuid()).ToList();
Assert.Equal(ids, LearningRules.OrderItems(ids, false, Guid.NewGuid()));
}
[Fact]
public void Shuffled_order_is_stable_per_attempt_complete_and_differs_between_attempts()
{
var ids = Enumerable.Range(0, 12).Select(i => new Guid(i, 0, 0, [0, 0, 0, 0, 0, 0, 0, 1])).ToList();
var attempt = new Guid("11111111-2222-3333-4444-555555555555");
var first = LearningRules.OrderItems(ids, true, attempt);
var again = LearningRules.OrderItems(ids, true, attempt);
Assert.Equal(first, again);
Assert.Equal(ids.Order(), first.Order()); // nichts verloren, nichts doppelt
Assert.NotEqual(ids, first); // tatsächlich gemischt (Wahrscheinlichkeit des Zufallstreffers 1/12!)
var others = Enumerable.Range(0, 5).Select(i => LearningRules.OrderItems(ids, true, new Guid(i + 1, 9, 9, [1, 2, 3, 4, 5, 6, 7, 8]))).ToList();
Assert.Contains(others, o => !o.SequenceEqual(first));
}
[Fact]
public void Shuffled_order_is_deterministic_across_processes()
{
// Fester Erwartungswert: die Mischung hängt nur von SHA-256 ab, nicht von string.GetHashCode (pro Prozess zufällig).
var ids = new[] { "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000003" }
.Select(Guid.Parse).ToList();
var attempt = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
var order = LearningRules.OrderItems(ids, true, attempt);
Assert.Equal(LearningRules.OrderItems(ids, true, attempt), order);
Assert.Equal(ids.Count, order.Distinct().Count());
Assert.Equal("2,1,3", string.Join(",", order.Select(o => o.ToString()[^1]))); // gepinnt: ändert sich die Mischung, wären laufende Versuche betroffen
}
[Fact]
public void Item_order_survives_serialisation_and_ignores_garbage()
{
var ids = Enumerable.Range(0, 5).Select(_ => Guid.NewGuid()).ToList();
Assert.Equal(ids, LearningRules.ParseOrder(LearningRules.SerializeOrder(ids)));
Assert.Empty(LearningRules.ParseOrder(null));
Assert.Empty(LearningRules.ParseOrder(""));
Assert.Equal([ids[0]], LearningRules.ParseOrder($"{ids[0]},kaputt,,{Guid.Empty}"));
Assert.True(LearningRules.SerializeOrder(Enumerable.Range(0, LearningSet.MaxItems).Select(_ => Guid.NewGuid())).Length <= LearningAttempt.ItemOrderMaxLength);
}
// ---------- Elternbewertung ----------
[Theory]
[InlineData(LearningVerdict.Correct, null, 4, 4, null)]
[InlineData(LearningVerdict.Correct, 2, 4, 4, null)] // Punkte werden bei „richtig“ ignoriert
[InlineData(LearningVerdict.Wrong, 3, 4, 0, null)]
[InlineData(LearningVerdict.Partial, 3, 4, 3, null)]
[InlineData(LearningVerdict.Partial, 1, 4, 1, null)]
[InlineData(LearningVerdict.Partial, null, 4, 2, null)] // Standard: Hälfte
[InlineData(LearningVerdict.Partial, null, 3, 1, null)] // abgerundet
[InlineData(LearningVerdict.Partial, null, 2, 1, null)]
public void Parent_verdicts_are_normalised(LearningVerdict verdict, int? awarded, int itemPoints, int expectedPoints, string? expectedError)
{
var (points, error) = LearningRules.NormalizeParentVerdict(verdict, awarded, itemPoints);
Assert.Equal((expectedPoints, expectedError), (points, error));
}
[Theory]
[InlineData(LearningVerdict.Partial, 4, 4)] // teilweise darf nicht alles sein
[InlineData(LearningVerdict.Partial, 0, 4)]
[InlineData(LearningVerdict.Partial, -1, 4)]
[InlineData(LearningVerdict.Partial, 1, 1)] // bei 1 Punkt gibt es keine Teilpunkte
[InlineData(LearningVerdict.Partial, null, 1)]
[InlineData(LearningVerdict.Open, null, 4)] // „offen“ ist kein Prüfergebnis
public void Invalid_parent_verdicts_are_rejected(LearningVerdict verdict, int? awarded, int itemPoints) =>
Assert.NotNull(LearningRules.NormalizeParentVerdict(verdict, awarded, itemPoints).Error);
// ---------- Punkte ----------
[Fact]
public void Score_counts_correct_partial_and_ignores_open_answers()
{
var score = LearningRules.Score(
[
new ScoredAnswer(2, LearningVerdict.Correct, 2),
new ScoredAnswer(4, LearningVerdict.Partial, 3),
new ScoredAnswer(1, LearningVerdict.Wrong, 0),
new ScoredAnswer(3, LearningVerdict.Open, 0),
new ScoredAnswer(2, LearningVerdict.Partial, 9), // zu viele Teilpunkte werden auf die Aufgabenpunkte begrenzt
]);
Assert.Equal((7, 12, 1, 1, 5), (score.Earned, score.Total, score.OpenCount, score.CorrectCount, score.AnswerCount));
Assert.Equal(7 * 100.0 / 12, score.Percent, 6);
Assert.Equal(0, LearningRules.Score([]).Percent);
}
[Theory]
[InlineData(10, 4, 4, 10)]
[InlineData(10, 3, 4, 8)] // 7,5 → 8 (kaufmännisch)
[InlineData(10, 2, 4, 5)]
[InlineData(10, 1, 4, 3)] // 2,5 → 3
[InlineData(10, 0, 4, 0)]
[InlineData(7, 1, 3, 2)] // 2,33 → 2
[InlineData(7, 2, 3, 5)] // 4,67 → 5
[InlineData(20, 3, 4, 15)]
[InlineData(0, 4, 4, 0)] // Blatt ohne Punkte
[InlineData(100, 1, 3, 33)]
public void Credit_is_proportional_and_rounded_half_up(int sheetPoints, int earned, int total, int expected) =>
Assert.Equal(expected, LearningRules.TargetCredit(sheetPoints, [(earned, total)]));
[Fact]
public void Only_the_best_confirmed_attempt_counts_once()
{
Assert.Equal(0, LearningRules.TargetCredit(10, []));
Assert.Equal(8, LearningRules.TargetCredit(10, [(2, 4), (3, 4), (1, 4)]));
Assert.Equal(10, LearningRules.TargetCredit(10, [(2, 4), (4, 4), (4, 4)])); // gleich gute Versuche zählen nicht doppelt
Assert.Equal(7, LearningRules.TargetCredit(10, [(7, 10)]));
// Anteile statt absoluter Punkte: 2/3 (67 %) schlägt 3/5 (60 %)
Assert.Equal(7, LearningRules.TargetCredit(10, [(3, 5), (2, 3)]));
Assert.Equal(0, LearningRules.TargetCredit(10, [(0, 0)])); // leerer Versuch ohne Punkte
}
[Theory]
[InlineData(8, 5, 3)]
[InlineData(5, 8, -3)] // Rückbuchung
[InlineData(5, 5, 0)] // idempotent
[InlineData(0, 0, 0)]
public void Credit_delta_is_the_difference_to_what_was_booked(int target, int booked, int expected) =>
Assert.Equal(expected, LearningRules.CreditDelta(target, booked));
[Fact]
public void Reopening_the_best_attempt_falls_back_to_the_next_best()
{
var all = new[] { (2, 4), (3, 4), (1, 4) };
var booked = LearningRules.TargetCredit(10, all); // 8
var afterReopen = LearningRules.TargetCredit(10, all.Where(a => a != (3, 4)));
Assert.Equal((8, 5, -3), (booked, afterReopen, LearningRules.CreditDelta(afterReopen, booked)));
}
}
@@ -0,0 +1,153 @@
using FamilyPlanner.Domain.Learning;
using FamilyPlanner.Shared.Learning;
namespace FamilyPlanner.UnitTests.Learning;
public sealed class LearningValidatorTests
{
private static readonly Guid Child = Guid.NewGuid();
private static LearningItemInput Number() => new("Was ist 7 + 5?", LearningAnswerType.Number, "12", Points: 2, Difficulty: 1);
private static CreateLearningSetRequest Valid() => new(
"Plus und Minus", LearningSubjects.Math, 2, Child, new DateOnly(2026, 10, 20), LearningMode.Exam, 1, 30, false, true, 10, "90;80;70;60;50",
[
Number(),
new("Hauptstadt?", LearningAnswerType.SingleChoice, "1", Options: ["Paris", "Berlin"], Hint: "Spree"),
new("Schreibe „Haus“.", LearningAnswerType.Text, "Haus"),
]);
[Fact]
public void Valid_requests_pass()
{
var validator = new CreateLearningSetRequestValidator();
Assert.True(validator.Validate(Valid()).IsValid);
Assert.True(validator.Validate(Valid() with { Items = null, GradeThresholds = null, TimeLimitMinutes = null, MaxAttempts = 0 }).IsValid);
Assert.True(new UpdateLearningSetRequestValidator().Validate(new UpdateLearningSetRequest("Titel", LearningSubjects.German, 6, Child)).IsValid);
}
public static TheoryData<CreateLearningSetRequest, string> InvalidSets => new()
{
{ Valid() with { Title = " " }, nameof(CreateLearningSetRequest.Title) },
{ Valid() with { Title = new string('x', LearningSet.TitleMaxLength + 1) }, nameof(CreateLearningSetRequest.Title) },
{ Valid() with { Subject = "Sport" }, nameof(CreateLearningSetRequest.Subject) },
{ Valid() with { Subject = "mathe" }, nameof(CreateLearningSetRequest.Subject) },
{ Valid() with { GradeLevel = 0 }, nameof(CreateLearningSetRequest.GradeLevel) },
{ Valid() with { GradeLevel = 7 }, nameof(CreateLearningSetRequest.GradeLevel) },
{ Valid() with { TargetUserId = Guid.Empty }, nameof(CreateLearningSetRequest.TargetUserId) },
{ Valid() with { Mode = (LearningMode)5 }, nameof(CreateLearningSetRequest.Mode) },
{ Valid() with { MaxAttempts = -1 }, nameof(CreateLearningSetRequest.MaxAttempts) },
{ Valid() with { MaxAttempts = LearningSet.AttemptsLimit + 1 }, nameof(CreateLearningSetRequest.MaxAttempts) },
{ Valid() with { TimeLimitMinutes = 0 }, nameof(CreateLearningSetRequest.TimeLimitMinutes) },
{ Valid() with { TimeLimitMinutes = LearningSet.MaxTimeLimitMinutes + 1 }, nameof(CreateLearningSetRequest.TimeLimitMinutes) },
{ Valid() with { TotalPoints = -1 }, nameof(CreateLearningSetRequest.TotalPoints) },
{ Valid() with { TotalPoints = LearningSet.MaxTotalPoints + 1 }, nameof(CreateLearningSetRequest.TotalPoints) },
{ Valid() with { GradeThresholds = "1;2;3" }, nameof(CreateLearningSetRequest.GradeThresholds) },
{ Valid() with { GradeThresholds = "30;50;67;81;92" }, nameof(CreateLearningSetRequest.GradeThresholds) },
{ Valid() with { Items = Enumerable.Range(0, LearningSet.MaxItems + 1).Select(_ => Number()).ToList() }, nameof(CreateLearningSetRequest.Items) },
};
[Theory]
[MemberData(nameof(InvalidSets))]
public void Invalid_sets_are_rejected_with_a_field_error(CreateLearningSetRequest request, string field)
{
var result = new CreateLearningSetRequestValidator().Validate(request);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == field);
}
public static TheoryData<LearningItemInput, string> InvalidItems => new()
{
{ Number() with { Question = " " }, nameof(LearningItemInput.Question) },
{ Number() with { Question = new string('x', LearningItem.QuestionMaxLength + 1) }, nameof(LearningItemInput.Question) },
{ Number() with { AnswerType = LearningAnswerType.Paper }, nameof(LearningItemInput.AnswerType) }, // erst Phase 10c
{ Number() with { AnswerType = (LearningAnswerType)9 }, nameof(LearningItemInput.AnswerType) },
{ Number() with { CorrectAnswer = "zwölf" }, nameof(LearningItemInput.CorrectAnswer) },
{ Number() with { CorrectAnswer = null }, nameof(LearningItemInput.CorrectAnswer) },
{ Number() with { CorrectAnswer = new string('1', LearningItem.CorrectAnswerMaxLength + 1) }, nameof(LearningItemInput.CorrectAnswer) },
{ new("Text?", LearningAnswerType.Text, " "), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["nur eine"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: null), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "2", Options: ["a", "b"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "x", Options: ["a", "b"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["a", "b", "c", "d", "e", "f", "g"]), nameof(LearningItemInput.CorrectAnswer) },
{ new("Wahl?", LearningAnswerType.SingleChoice, "0", Options: ["a", " "]), nameof(LearningItemInput.Options) },
{ Number() with { Difficulty = 0 }, nameof(LearningItemInput.Difficulty) },
{ Number() with { Difficulty = 4 }, nameof(LearningItemInput.Difficulty) },
{ Number() with { Points = 0 }, nameof(LearningItemInput.Points) },
{ Number() with { Points = LearningItem.MaxPoints + 1 }, nameof(LearningItemInput.Points) },
{ Number() with { NumberTolerance = -1m }, nameof(LearningItemInput.NumberTolerance) },
{ Number() with { Hint = new string('h', LearningItem.HintMaxLength + 1) }, nameof(LearningItemInput.Hint) },
{ Number() with { Explanation = new string('e', LearningItem.ExplanationMaxLength + 1) }, nameof(LearningItemInput.Explanation) },
};
[Theory]
[MemberData(nameof(InvalidItems))]
public void Invalid_items_are_rejected_with_a_field_error(LearningItemInput item, string field)
{
var result = new LearningItemInputValidator().Validate(item);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == field);
}
[Fact]
public void Invalid_items_inside_a_set_and_update_requests_are_rejected_too()
{
var result = new CreateLearningSetRequestValidator().Validate(Valid() with { Items = [Number(), Number() with { Points = 99 }] });
Assert.False(result.IsValid);
var update = UpdateLearningItemRequest.From(new LearningItemDto(Guid.NewGuid(), 0, "Frage", LearningAnswerType.Number, "5", null, [], null, null, 1, 1, [1]));
Assert.True(new UpdateLearningItemRequestValidator().Validate(update).IsValid);
Assert.False(new UpdateLearningItemRequestValidator().Validate(update with { CorrectAnswer = "x" }).IsValid);
}
[Fact]
public void Item_entities_are_built_from_the_fields_without_leftovers()
{
var item = LearningValidation.ToItem(new LearningItemInput(
" Frage ", LearningAnswerType.Number, " 12 ", 0.5m, ["ignoriert"], " Hinweis ", " ", 2, 3));
Assert.Equal(("Frage", "12", 0.5m, "Hinweis", null), (item.Question, item.CorrectAnswer, item.NumberTolerance, item.Hint, item.Explanation));
Assert.Empty(item.Options); // Optionen gibt es nur bei Auswahlaufgaben
var choice = LearningValidation.ToItem(new LearningItemInput("Wahl", LearningAnswerType.SingleChoice, "1", 3m, [" a ", "b"]));
Assert.Equal(["a", "b"], choice.Options);
Assert.Null(choice.NumberTolerance); // Toleranz nur bei Zahlen
}
[Fact]
public void Reorder_save_answer_review_and_confirm_requests_are_validated()
{
var ids = new[] { Guid.NewGuid(), Guid.NewGuid() };
Assert.True(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest(ids)).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest([ids[0], ids[0]])).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest([Guid.Empty])).IsValid);
Assert.False(new ReorderLearningItemsRequestValidator().Validate(new ReorderLearningItemsRequest(null!)).IsValid);
Assert.True(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest("12")).IsValid);
Assert.True(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest(null)).IsValid);
Assert.False(new SaveAnswerRequestValidator().Validate(new SaveAnswerRequest(new string('x', LearningAnswer.ValueMaxLength + 1))).IsValid);
var review = new ReviewAnswerRequestValidator();
Assert.True(review.Validate(new ReviewAnswerRequest(LearningVerdict.Correct)).IsValid);
Assert.True(review.Validate(new ReviewAnswerRequest(LearningVerdict.Partial, 2, "Fast richtig")).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Open)).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Partial, 0)).IsValid);
Assert.False(review.Validate(new ReviewAnswerRequest(LearningVerdict.Wrong, null, new string('x', LearningAnswer.ReasonMaxLength + 1))).IsValid);
Assert.True(new ConfirmAttemptRequestValidator().Validate(new ConfirmAttemptRequest(null)).IsValid);
Assert.False(new ConfirmAttemptRequestValidator().Validate(new ConfirmAttemptRequest(new string('x', LearningAttempt.NoteMaxLength + 1))).IsValid);
}
[Fact]
public void Subjects_are_a_fixed_list_for_now()
{
Assert.Equal(["Mathe", "Deutsch"], LearningSubjects.All);
Assert.True(LearningSubjects.IsValid("Mathe"));
Assert.False(LearningSubjects.IsValid("Englisch"));
Assert.False(LearningSubjects.IsValid(null));
}
}