using QuestionsApi.Models; using QuestionsApi.Services; using Xunit; namespace QuestionsApi.Tests; public sealed class MasteryCalculatorTests { private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc); [Fact] public void NoEvidenceIsUnseen() { var result = MasteryCalculator.Calculate([], [], [], new Dictionary(), Now); Assert.All(result, item => Assert.Equal(MasteryStates.Unseen, item.MasteryState)); } [Fact] public void EvidenceGatesStrongAndMasteredStates() { var questions = Enumerable.Range(0, 25).ToDictionary( index => $"q{index}", index => Question($"q{index}", index < 5 ? 4 : 3)); var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now.AddDays(-1))).ToList(); var strong = MasteryCalculator.Calculate(attempts.Take(15), [], [], questions, Now)[6]; var mastered = MasteryCalculator.Calculate(attempts, [], [], questions, Now)[6]; Assert.Equal(MasteryStates.Strong, strong.MasteryState); Assert.Equal(MasteryStates.Mastered, mastered.MasteryState); } [Fact] public void PendingReviewBlocksMasteredAndBecomesRecommendedAction() { var questions = Enumerable.Range(0, 25).ToDictionary( index => $"q{index}", index => Question($"q{index}", index < 5 ? 4 : 3)); var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now)).ToList(); var queue = new[] { new ReviewQueueItem { QuestionExternalId = "q0", State = ReviewQueueStates.Pending } }; var result = MasteryCalculator.Calculate(attempts, [], queue, questions, Now)[6]; Assert.Equal(MasteryStates.Strong, result.MasteryState); Assert.Equal("review", result.RecommendedAction.Type); } [Fact] public void DelayedReviewReceivesPartialRecoveryAndOldReviewDoesNotRecoverNewCycle() { var questions = new Dictionary { ["q"] = Question("q", 3) }; var first = Attempt("q", false, 0, Now.AddDays(-10)); var second = Attempt("q", false, 1, Now.AddDays(-2)); var reviews = new[] { new QuestionReviewAttempt { QuestionExternalId = "q", IsCorrect = true, CreatedUtc = Now.AddDays(-8) } }; var result = MasteryCalculator.Calculate([first, second], reviews, [], questions, Now)[6]; Assert.Equal(25, result.MasteryScore); } [Fact] public void StrongMasteryIsMarkedDueWithoutDecayingState() { var questions = Enumerable.Range(0, 15).ToDictionary(index => $"q{index}", index => Question($"q{index}", 4)); var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now.AddDays(-31))).ToList(); var result = MasteryCalculator.Calculate(attempts, [], [], questions, Now)[6]; Assert.Equal(MasteryStates.Strong, result.MasteryState); Assert.True(result.RefreshDue); } [Fact] public void SkipsDoNotCountAsEvidence() { var questions = new Dictionary { ["q"] = Question("q", 5) }; var skip = Attempt("q", false, 0, Now); skip.IsSkip = true; var result = MasteryCalculator.Calculate([skip], [], [], questions, Now)[6]; Assert.Equal(MasteryStates.Unseen, result.MasteryState); } private static Question Question(string id, int difficulty) => new() { ExternalId = id, System = "Cardiovascular System", Step3System = "Cardiovascular System", Difficulty = difficulty }; private static QuestionAttempt Attempt(string id, bool correct, int cycle, DateTime created) => new() { QuestionExternalId = id, UserId = "user", IsCorrect = correct, BankCycle = cycle, CreatedUtc = created }; }