Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
178 lines
6.0 KiB
C#
178 lines
6.0 KiB
C#
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));
|
|
}
|
|
|
|
private static readonly DateTime Today = new(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_NoReviews_IsZero()
|
|
{
|
|
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays([], Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_ReviewedTodayOnly_IsOne()
|
|
{
|
|
var timestamps = new List<DateTime> { Today.AddHours(9) };
|
|
|
|
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree()
|
|
{
|
|
var timestamps = new List<DateTime>
|
|
{
|
|
Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
|
|
};
|
|
|
|
Assert.Equal(3, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_NotYetReviewedToday_StillCountsThroughYesterday()
|
|
{
|
|
// The streak shouldn't visibly reset to 0 the instant a new calendar day begins, before
|
|
// the user has had a chance to study today.
|
|
var timestamps = new List<DateTime> { Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20) };
|
|
|
|
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_GapBeforeYesterday_BreaksTheStreak()
|
|
{
|
|
var timestamps = new List<DateTime>
|
|
{
|
|
Today.AddDays(-10).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
|
|
};
|
|
|
|
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_MissedYesterdayAndToday_IsZero()
|
|
{
|
|
var timestamps = new List<DateTime> { Today.AddDays(-3).AddHours(8) };
|
|
|
|
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak()
|
|
{
|
|
var timestamps = new List<DateTime> { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) };
|
|
|
|
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_NonShuffle_ReturnsFirstNonExcluded()
|
|
{
|
|
var pool = new List<string> { "a", "b", "c" };
|
|
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize"));
|
|
|
|
Assert.Equal(1, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull()
|
|
{
|
|
var pool = new List<string> { "a", "b" };
|
|
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: false, n => 0);
|
|
|
|
Assert.Null(picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle()
|
|
{
|
|
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: false, n => 0));
|
|
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: true, n => 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded()
|
|
{
|
|
var pool = new List<string> { "a", "b", "c" };
|
|
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: true, n => 0);
|
|
|
|
// Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1.
|
|
Assert.Equal(1, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate()
|
|
{
|
|
var pool = new List<string> { "a", "b", "c" };
|
|
|
|
// Stub picks the last candidate instead of always the first (oldest), proving this isn't
|
|
// hardcoded to FirstOrDefault-style behavior.
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string>(), shuffle: true, n => n - 1);
|
|
|
|
Assert.Equal(2, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping()
|
|
{
|
|
var pool = new List<string> { "a", "b", "c", "d" };
|
|
|
|
// Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub
|
|
// index 2 should map back to original index 3 ("d"), not 2 ("c").
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "b" }, shuffle: true, n => 2);
|
|
|
|
Assert.Equal(3, picked);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull()
|
|
{
|
|
var pool = new List<string> { "a", "b" };
|
|
|
|
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: true, n => 0);
|
|
|
|
Assert.Null(picked);
|
|
}
|
|
}
|