Add flashcards feature with spaced repetition scheduling
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.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user