using QuestionsApi.Models; using QuestionsApi.Services; using Xunit; namespace QuestionsApi.Tests; public sealed class ExamSimulationTests { [Fact] public void DayOneFormHasOfficialItemAndBlockCounts() { Assert.Equal(232, ExamSimulationStore.BlockSizes.Sum()); Assert.Equal(12, ExamSimulationStore.BlockSizes.Length); Assert.All(ExamSimulationStore.BlockSizes, size => Assert.InRange(size, 18, 20)); } [Fact] public void BlueprintTargetsFillTheCompleteForm() { var targets = ExamSimulationStore.BuildTargets(); Assert.Equal(ExamSimulationStore.RequiredQuestions, targets.Values.Sum()); Assert.Equal(14, targets.Count); Assert.All(targets.Values, count => Assert.True(count > 0)); } [Fact] public void MoreCorrectAnswersProduceAHigherPrediction() { var items = BuildItems(100); var questions = BuildQuestions(100); var allCorrect = items.ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var allWrong = items.ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "B" }); var passing = ExamSimulationScoring.Calculate(items, allCorrect, questions); var failing = ExamSimulationScoring.Calculate(items, allWrong, questions); Assert.True(passing.PredictedScore > failing.PredictedScore); Assert.True(passing.PassProbability > failing.PassProbability); Assert.Equal(100, passing.Correct); Assert.Equal(0, failing.Correct); } [Theory] [InlineData(0, 50)] [InlineData(20, 100)] [InlineData(60, 200)] [InlineData(100, 300)] public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore) { var items = BuildItems(100); var questions = BuildQuestions(100); var responses = items.Take(correctCount).ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var score = ExamSimulationScoring.Calculate(items, responses, questions); Assert.Equal(expectedScore, score.PredictedScore); Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4); Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4); } [Fact] public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction() { var questions = BuildQuestions(10); var hardCorrect = BuildItems(10); var easyCorrect = BuildItems(10); for (var index = 0; index < 10; index++) { hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1; easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5; } var responses = hardCorrect.Take(5).ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions); var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions); Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy); Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy); Assert.True(hardScore.PredictedScore > easyScore.PredictedScore); } [Fact] public void EmpiricalQuestionEloDoesNotAffectPrediction() { var questions = BuildQuestions(20); var lowElos = BuildItems(20); var highElos = BuildItems(20); foreach (var item in lowElos) item.QuestionElo = 100; foreach (var item in highElos) item.QuestionElo = 1900; var responses = lowElos.Take(10).ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions); var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions); Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore); Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability); } [Fact] public void SkippedAndUnansweredQuestionsCountAsIncorrect() { var items = BuildItems(10); var questions = BuildQuestions(10); var responses = new Dictionary { [items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" }, [items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" } }; var score = ExamSimulationScoring.Calculate(items, responses, questions); Assert.Equal(1, score.Correct); Assert.Equal(10, score.Total); Assert.Equal(0.1, score.RawAccuracy, 4); } [Fact] public void LowAccuracyFullLengthSimulationIsNotInflated() { var items = BuildItems(232); var questions = BuildQuestions(232); var responses = items.Take(49).ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var score = ExamSimulationScoring.Calculate(items, responses, questions); Assert.Equal(103, score.PredictedScore); Assert.True(score.PassProbability < 0.001); Assert.True(score.RangeHigh < 130); } [Fact] public void SeenItemsAreReportedButDoNotChangeScoring() { var questions = BuildQuestions(20); var unseen = BuildItems(20); var seen = BuildItems(20, wasSeen: true); var responses = unseen.ToDictionary( item => item.QuestionExternalId, item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); var unseenScore = ExamSimulationScoring.Calculate(unseen, responses, questions); var seenScore = ExamSimulationScoring.Calculate(seen, responses, questions); Assert.Equal(0, unseenScore.SeenCount); Assert.Equal(20, seenScore.SeenCount); Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore); Assert.Null(unseenScore.AbilityElo); Assert.Null(seenScore.AbilityElo); } [Theory] [InlineData("Cardiovascular System", "Cardiovascular")] [InlineData("Professionalism and Ethics", "Social Sciences")] [InlineData("Endocrine, Diabetes & Metabolism", "Endocrine")] public void QuestionsMapToExpectedBlueprintCategory(string system, string expected) { Assert.Equal(expected, ExamSimulationStore.BlueprintCategory(new Question { System = system })); } [Fact] public void HistorySummarySupportsScoredAndUnscoredStates() { var active = ExamSimulationStore.ToHistorySummary(new ExamSimulation { State = ExamSimulationStates.BlockActive, CurrentBlock = 3, Items = BuildItems(20) }); var completed = ExamSimulationStore.ToHistorySummary(new ExamSimulation { State = ExamSimulationStates.Completed, IsSample = true, Items = BuildItems(20), Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, PassProbability = 0.73, Correct = 15, Total = 20 } }); Assert.Null(active.PredictedScore); Assert.Equal(3, active.CurrentBlock); Assert.Equal(221, completed.PredictedScore); Assert.Equal(15, completed.Correct); Assert.Equal(0.73, completed.PassProbability); Assert.True(completed.IsSample); } [Fact] public void HistoryCursorRoundTripsTimestampAndObjectId() { var timestamp = new DateTime(2026, 7, 16, 22, 15, 30, DateTimeKind.Utc); const string id = "64b1d9f36f7a4e2b8c901234"; var encoded = ExamSimulationStore.EncodeHistoryCursor(timestamp, id); Assert.True(ExamSimulationStore.TryDecodeHistoryCursor(encoded, out var decoded)); Assert.NotNull(decoded); Assert.Equal(timestamp, decoded.CreatedUtc); Assert.Equal(id, decoded.Id); Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); } [Theory] [InlineData(ExamSimulationStates.BetweenBlocks, true)] [InlineData(ExamSimulationStates.Completed, true)] [InlineData(ExamSimulationStates.BlockActive, false)] [InlineData(ExamSimulationStates.Abandoned, false)] public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected) { Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state)); } private static List BuildItems(int count, bool wasSeen = false) => Enumerable.Range(0, count).Select(index => new ExamSimulationItem { QuestionExternalId = $"question-{index}", QuestionElo = 1000, QuestionDifficulty = 3, WasSeen = wasSeen, BlueprintCategory = "Cardiovascular" }).ToList(); private static Dictionary BuildQuestions(int count) => Enumerable.Range(0, count).ToDictionary( index => $"question-{index}", index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 }); }