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 { Today.AddHours(9) }; Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); } [Fact] public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree() { var timestamps = new List { 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 { 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 { 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 { Today.AddDays(-3).AddHours(8) }; Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); } [Fact] public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak() { var timestamps = new List { 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 { "a", "b", "c" }; var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "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 { "a", "b" }; var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: false, n => 0); Assert.Null(picked); } [Fact] public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle() { Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: false, n => 0)); Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: true, n => 0)); } [Fact] public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded() { var pool = new List { "a", "b", "c" }; var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "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 { "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(), shuffle: true, n => n - 1); Assert.Equal(2, picked); } [Fact] public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping() { var pool = new List { "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 { "b" }, shuffle: true, n => 2); Assert.Equal(3, picked); } [Fact] public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull() { var pool = new List { "a", "b" }; var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: true, n => 0); Assert.Null(picked); } }