using FSRS.Core.Constants; using QuestionsApi.Models; using QuestionsApi.Services.Flashcards; using Xunit; namespace QuestionsApi.Tests; // Validates FsrsFlashcardScheduler against the official FSRS formulas (not just against the // library's own outputs), using the exact weights FSRS.Core 1.0.7 ships via FsrsConstants so // this keeps validating the formula even if the shipped defaults are ever bumped upstream. // // S0(rating) = w[(int)rating - 1] (initial stability) // D0(rating) = clamp(w4 - (e^(w5*((int)rating-1)) - 1), 1, 10) (initial difficulty) // I(r, S) = round(S) when desired_retention == the FSRS reference retention (0.9), because // the interval formula collapses to the stability itself at that specific retention. public sealed class FlashcardSchedulerTests { private static readonly double[] W = FsrsConstants.DefaultParameters; private static readonly DateTime Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static FlashcardSchedule NewSchedule() => new() { UserId = "user-1", NoteId = "note-1", State = FlashcardScheduleStates.New, DueUtc = Start, LastReviewUtc = null }; [Theory] [InlineData(FlashcardRatings.Again, 0)] [InlineData(FlashcardRatings.Hard, 1)] [InlineData(FlashcardRatings.Good, 2)] [InlineData(FlashcardRatings.Easy, 3)] public void InitialStability_MatchesFsrsFormula(string rating, int weightIndex) { var scheduler = new FsrsFlashcardScheduler(); var result = scheduler.Review(NewSchedule(), rating, Start, 0); Assert.Equal(W[weightIndex], result.Stability, precision: 6); } [Theory] [InlineData(FlashcardRatings.Again, 1)] [InlineData(FlashcardRatings.Hard, 2)] [InlineData(FlashcardRatings.Good, 3)] [InlineData(FlashcardRatings.Easy, 4)] public void InitialDifficulty_MatchesFsrsFormula(string rating, int fsrsRatingValue) { var scheduler = new FsrsFlashcardScheduler(); var result = scheduler.Review(NewSchedule(), rating, Start, 0); var expected = Math.Clamp(W[4] - (Math.Exp(W[5] * (fsrsRatingValue - 1)) - 1), 1, 10); Assert.Equal(expected, result.Difficulty, precision: 6); } [Fact] public void Interval_EqualsRoundedNewStability_AtPinnedDesiredRetention() { // At desired_retention == 0.9 (the reference retention baked into the FSRS interval // formula), I(0.9, S) reduces to S regardless of the decay parameter - this holds for // any state, so it's a strong end-to-end check that both the stability update and the // interval formula are wired correctly, without hand-deriving decay/factor constants. var scheduler = new FsrsFlashcardScheduler(); var schedule = NewSchedule(); var t = Start; foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good, FlashcardRatings.Good }) { var result = scheduler.Review(schedule, rating, t, 0); schedule = ToSchedule(schedule, result); t = result.DueUtc; } var final = scheduler.Review(schedule, FlashcardRatings.Good, t, 0); var intervalDays = (final.DueUtc - t).TotalDays; Assert.Equal(Math.Round(final.Stability, MidpointRounding.AwayFromZero), Math.Round(intervalDays), precision: 6); } [Fact] public void MaximumInterval_NeverExceeds36500Days() { var scheduler = new FsrsFlashcardScheduler(); var schedule = NewSchedule(); schedule.State = FlashcardScheduleStates.Review; schedule.Stability = 1_000_000; schedule.Difficulty = 5; schedule.DueUtc = Start; schedule.LastReviewUtc = Start.AddDays(-1); var result = scheduler.Review(schedule, FlashcardRatings.Good, Start, 0); Assert.True((result.DueUtc - Start).TotalDays <= FsrsFlashcardScheduler.MaximumIntervalDays); } [Fact] public void Again_MovesToRelearning_AndReducesStabilityBelowPriorReview() { var scheduler = new FsrsFlashcardScheduler(); var schedule = NewSchedule(); var t = Start; FlashcardScheduleResult result = null!; foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good }) { result = scheduler.Review(schedule, rating, t, 0); schedule = ToSchedule(schedule, result); t = result.DueUtc; } var stabilityBeforeLapse = schedule.Stability; var lapsed = scheduler.Review(schedule, FlashcardRatings.Again, t, 0); Assert.Equal(FlashcardScheduleStates.Relearning, lapsed.State); Assert.True(lapsed.Stability < stabilityBeforeLapse); } [Fact] public void PreviewRatingOptions_ReturnsAllFourRatings_WithNonDecreasingDueDates() { var scheduler = new FsrsFlashcardScheduler(); var schedule = NewSchedule(); schedule.State = FlashcardScheduleStates.Review; schedule.Stability = 10; schedule.Difficulty = 5; schedule.LastReviewUtc = Start.AddDays(-10); schedule.DueUtc = Start; var options = scheduler.PreviewRatingOptions(schedule, Start); Assert.Equal(FlashcardRatings.All.Length, options.Count); Assert.Equal(FlashcardRatings.All.ToHashSet(), options.Select(o => o.Rating).ToHashSet()); var again = options.Single(o => o.Rating == FlashcardRatings.Again).DueUtc; var hard = options.Single(o => o.Rating == FlashcardRatings.Hard).DueUtc; var good = options.Single(o => o.Rating == FlashcardRatings.Good).DueUtc; var easy = options.Single(o => o.Rating == FlashcardRatings.Easy).DueUtc; Assert.True(again <= hard); Assert.True(hard <= good); Assert.True(good <= easy); } private static FlashcardSchedule ToSchedule(FlashcardSchedule prior, FlashcardScheduleResult result) => new() { UserId = prior.UserId, NoteId = prior.NoteId, Variant = prior.Variant, State = result.State, Step = result.Step, Stability = result.Stability, Difficulty = result.Difficulty, DueUtc = result.DueUtc, LastReviewUtc = result.LastReviewUtc }; }