Adds a full flashcards system: FSRS-based scheduling, cloze/basic card types, CSV import/export, media storage, and study UI, wired into navigation and the dashboard alongside existing question flows.
153 lines
6.1 KiB
C#
153 lines
6.1 KiB
C#
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
|
|
};
|
|
}
|