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,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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user