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:
hz
2026-07-21 15:08:52 -05:00
parent 29b89dc404
commit 48a8c8361d
41 changed files with 4458 additions and 8 deletions
@@ -0,0 +1,94 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardClozeParserTests
{
[Fact]
public void Parse_ExtractsAnswerAndHint()
{
var deletions = FlashcardClozeParser.Parse("The capital of France is {{c1::Paris::country capital}}.");
var deletion = Assert.Single(deletions);
Assert.Equal(1, deletion.Number);
Assert.Equal("Paris", deletion.Answer);
Assert.Equal("country capital", deletion.Hint);
}
[Fact]
public void Parse_WithoutHint_LeavesHintNull()
{
var deletions = FlashcardClozeParser.Parse("{{c1::Paris}}");
Assert.Null(Assert.Single(deletions).Hint);
}
[Fact]
public void DistinctClozeNumbers_DeduplicatesRepeatedNumbersAndSorts()
{
var text = "{{c2::b}} some text {{c1::a}} more {{c1::a-again}} {{c3::c}}";
var numbers = FlashcardClozeParser.DistinctClozeNumbers(text);
Assert.Equal([1, 2, 3], numbers);
}
[Theory]
[InlineData("no cloze markers here")]
[InlineData("{{c0::invalid, number must be >= 1}}")]
[InlineData("{{cX::not a number}}")]
[InlineData("{{c1::}}")]
public void HasValidCloze_IsFalse_ForMalformedOrMissingMarkers(string text)
{
Assert.False(FlashcardClozeParser.HasValidCloze(text));
}
[Fact]
public void HasValidCloze_IsTrue_WhenAtLeastOneValidMarkerExists()
{
Assert.True(FlashcardClozeParser.HasValidCloze("prefix {{c1::answer}} suffix"));
}
[Fact]
public void RenderFront_HidesTargetNumber_RevealsOthers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France}}";
var front1 = FlashcardClozeParser.RenderFront(text, 1);
var front2 = FlashcardClozeParser.RenderFront(text, 2);
Assert.Equal("[...] is the capital of France", front1);
Assert.Equal("Paris is the capital of [...]", front2);
}
[Fact]
public void RenderFront_UsesHintWhenPresent()
{
var text = "{{c1::Paris::capital city}} is in France";
var front = FlashcardClozeParser.RenderFront(text, 1);
Assert.Equal("[capital city] is in France", front);
}
[Fact]
public void RenderBack_RevealsAllAnswers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France::hint}}";
var back = FlashcardClozeParser.RenderBack(text, 1);
Assert.Equal("Paris is the capital of France", back);
}
[Fact]
public void Parse_SupportsMultilineClozeContent()
{
var text = "Line one\n{{c1::multi\nline\nanswer}}\nLine two";
var deletion = Assert.Single(FlashcardClozeParser.Parse(text));
Assert.Equal("multi\nline\nanswer", deletion.Answer);
}
}
@@ -0,0 +1,75 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardCsvTests
{
[Fact]
public void ParseRows_HandlesQuotedMultilineFieldsAndEmbeddedCommas()
{
var csv = "stable_id,deck,type,prompt,answer,extra,tags,source_question_external_id\n"
+ "abc-1,Cardio,basic,\"What is the first line,\nsecond line?\",\"Answer, with comma\",,tag1;tag2,q-1\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
var data = rows[1];
Assert.Equal("abc-1", data[0]);
Assert.Equal("What is the first line,\nsecond line?", data[3]);
Assert.Equal("Answer, with comma", data[4]);
Assert.Equal("tag1;tag2", data[6]);
}
[Fact]
public void ParseRows_HandlesEscapedDoubleQuotes()
{
var csv = "a,b\n\"she said \"\"hi\"\"\",plain\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("she said \"hi\"", rows[1][0]);
Assert.Equal("plain", rows[1][1]);
}
[Fact]
public void ParseRows_SupportsUnicodeContent()
{
var csv = "a,b\néè中文,\U0001F600\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("éè中文", rows[1][0]);
Assert.Equal("😀", rows[1][1]);
}
[Fact]
public void ParseRows_HandlesFileWithoutTrailingNewline()
{
var csv = "a,b\n1,2";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
Assert.Equal(["1", "2"], rows[1]);
}
[Fact]
public void WriteRow_QuotesFieldsContainingSpecialCharacters()
{
var row = FlashcardCsv.WriteRow(["plain", "has,comma", "has\"quote", "has\nnewline"]);
Assert.Equal("plain,\"has,comma\",\"has\"\"quote\",\"has\nnewline\"", row);
}
[Fact]
public void WriteThenParse_RoundTrips()
{
var original = new[] { "id-1", "Deck, Name", "basic", "prompt with \"quotes\" and\nnewline", "answer", "", "a;b", "q-1" };
var line = FlashcardCsv.WriteRow(original);
var rows = FlashcardCsv.ParseRows("header\n" + line + "\n");
Assert.Equal(original, rows[1]);
}
}
@@ -0,0 +1,92 @@
using System.Text.Json;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardImportExportModelTests
{
[Fact]
public void JsonExport_RoundTrips_NotesCardsAndReviewHistory()
{
var export = new FlashcardJsonExport
{
SchemaVersion = 1,
Settings = new FlashcardSettings { UserId = "user-1", NewCardsPerDay = 20, ReviewsPerDay = 200 },
Decks = [new FlashcardDeck { Id = "deck-1", UserId = "user-1", Name = "Cardio", IsDefault = true }],
Notes =
[
new FlashcardJsonExportNote
{
Note = new FlashcardNote
{
Id = "note-1", UserId = "user-1", DeckId = "deck-1", StableId = "stable-1",
Type = FlashcardTypes.Cloze, Prompt = "{{c1::Paris}} is the capital of France",
Tags = ["geography"]
},
Cards =
[
new FlashcardSchedule
{
Id = "card-1", UserId = "user-1", NoteId = "note-1", Variant = 1,
State = FlashcardScheduleStates.Review, Stability = 12.3, Difficulty = 4.5,
DueUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
}
],
ReviewLogs =
[
new FlashcardReviewLog
{
Id = "log-1", UserId = "user-1", CardId = "card-1", Rating = FlashcardRatings.Good,
ReviewedUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)
}
],
Images = [new FlashcardJsonExportImage { AssetId = "asset-1", FileName = "x.png", ContentType = "image/png", Base64Bytes = Convert.ToBase64String([1, 2, 3]) }]
}
]
};
var json = JsonSerializer.Serialize(export);
var roundTripped = JsonSerializer.Deserialize<FlashcardJsonExport>(json);
Assert.NotNull(roundTripped);
var note = Assert.Single(roundTripped!.Notes);
Assert.Equal("stable-1", note.Note.StableId);
Assert.Equal(FlashcardTypes.Cloze, note.Note.Type);
var card = Assert.Single(note.Cards);
Assert.Equal(1, card.Variant);
Assert.Equal(12.3, card.Stability);
var log = Assert.Single(note.ReviewLogs);
Assert.Equal(FlashcardRatings.Good, log.Rating);
var image = Assert.Single(note.Images);
Assert.Equal([1, 2, 3], Convert.FromBase64String(image.Base64Bytes));
}
[Fact]
public void Fingerprint_IsStable_ForSameTypePromptAndAnswer_IgnoringSurroundingWhitespace()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, " What is 2+2? ", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
Assert.Equal(a, b);
}
[Fact]
public void Fingerprint_DiffersWhenAnswerDiffers()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "five");
Assert.NotEqual(a, b);
}
[Fact]
public void Fingerprint_DiffersByType_ForSamePromptAndAnswer()
{
var basic = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "text", "text");
var cloze = FlashcardNoteStore.Fingerprint(FlashcardTypes.Cloze, "text", "text");
Assert.NotEqual(basic, cloze);
}
}
@@ -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
};
}
@@ -0,0 +1,41 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardSchedulingTests
{
[Fact]
public void NextStudyDayStart_IsMidnightOfTheFollowingDay()
{
var studyDayStart = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void NextStudyDayStart_TruncatesTimeOfDayBeforeAdvancing()
{
// A session that started mid-day still buries siblings until the *next calendar day*,
// not exactly 24h later.
var studyDayStart = new DateTime(2026, 3, 5, 14, 30, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Theory]
[InlineData(20, null, 0, 20)]
[InlineData(20, null, 15, 5)]
[InlineData(20, null, 25, 0)]
[InlineData(20, 5, 0, 5)]
[InlineData(20, 0, 0, 0)]
public void ResolveDailyBudget_DeckOverrideWinsOverGlobal_ClampedAtZero(
int globalLimit, int? deckOverride, int consumedToday, int expected)
{
Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday));
}
}