using QuestionsApi.Models; using QuestionsApi.Services; using Xunit; namespace QuestionsApi.Tests; public sealed class ChoicePoolTests { [Fact] public void LegacyResponseContainsFiveChoicesAndAlwaysIncludesCorrectAnswer() { var question = BuildQuestion(9); for (var iteration = 0; iteration < 50; iteration++) { var response = QuestionResponse.FromModel(question, includeChoicePool: false); Assert.Equal(5, response.Choices.Count); Assert.Equal(5, response.Choices.Select(choice => choice.Id).Distinct().Count()); Assert.Contains(response.Choices, choice => choice.Id == question.CorrectChoiceId); Assert.All(response.ChoiceExplanations.Keys, id => Assert.Contains(response.Choices, choice => choice.Id == id)); Assert.All(response.WrongAnswerFollowups.Keys, id => Assert.Contains(response.Choices, choice => choice.Id == id)); } } [Fact] public void PoolCapableResponseContainsCompletePool() { var question = BuildQuestion(9); var response = QuestionResponse.FromModel(question, includeChoicePool: true); Assert.Equal(9, response.Choices.Count); Assert.Single(response.References); Assert.Equal("https://example.org/guideline", response.References[0].Url); Assert.Equal("Key teaching point", response.KeyTakeaway); Assert.Equal("Exam pearl", response.UsmlePearl); Assert.Single(response.ReviewFollowups); } [Fact] public void HigherSelectionRatesIncreaseDistractorPresentationFrequency() { var question = BuildQuestion(9); var rates = question.Choices.ToDictionary(choice => choice.Id, _ => 0); rates["B"] = 100; var random = new Random(12345); var counts = question.Choices.ToDictionary(choice => choice.Id, _ => 0); for (var iteration = 0; iteration < 2000; iteration++) { foreach (var id in ChoicePoolSelector.SelectChoiceIds(question, rates, random)) counts[id]++; } Assert.All(question.Choices.Where(choice => choice.Id is not "A" and not "B"), choice => Assert.True(counts["B"] > counts[choice.Id])); Assert.Equal(2000, counts["A"]); } [Fact] public void MissingSelectionRatesStillAllowDifferentDistractorSets() { var question = BuildQuestion(9); var random = new Random(9876); var sets = Enumerable.Range(0, 40) .Select(_ => string.Join(",", ChoicePoolSelector.SelectChoiceIds( question, new Dictionary(), random).Order())) .ToHashSet(); Assert.True(sets.Count > 1); } [Fact] public void ExamReviewContainsOnlyChoicesPresentedDuringTheExam() { var question = BuildQuestion(9); var presented = new[] { "F", "A", "C", "H", "D" }; var response = QuestionResponse.FromModelWithChoices(question, presented); Assert.Equal(presented, response.Choices.Select(choice => choice.Id)); Assert.Equal(presented.Order(), response.ChoiceExplanations.Keys.Order()); Assert.DoesNotContain("B", response.Choices.Select(choice => choice.Id)); } [Fact] public void PublishedQuestionRequiresCompletePerChoiceTeachingContent() { var request = BuildRequest(6); Assert.Null(QuestionValidator.Validate(request)); request.WrongAnswerFollowups.Remove("F"); Assert.Contains("every incorrect choice", QuestionValidator.Validate(request)); } [Fact] public void OptionalReviewFollowupsRequireValidAuthoredContent() { var request = BuildRequest(5); Assert.Null(QuestionValidator.Validate(request)); request.ReviewFollowups[0].CorrectChoiceId = "missing"; Assert.Contains("reviewFollowups correctChoiceId", QuestionValidator.Validate(request)); request = BuildRequest(5); request.ReviewFollowups[0].TeachingPoint = ""; Assert.Contains("reviewFollowups teachingPoint", QuestionValidator.Validate(request)); } [Fact] public void LegacyQuestionWithoutReviewContentRemainsValid() { var request = BuildRequest(5); request.KeyTakeaway = ""; request.UsmlePearl = ""; request.ReviewFollowups = []; Assert.Null(QuestionValidator.Validate(request)); } [Fact] public void ActiveSimulationQuestionContractDoesNotExposeReviewContent() { Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.Explanation))); Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.KeyTakeaway))); Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.UsmlePearl))); Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.ReviewFollowups))); } [Fact] public void PublishedQuestionRequiresAtLeastFiveUniqueChoices() { var request = BuildRequest(4); Assert.Contains("at least five", QuestionValidator.Validate(request)); request = BuildRequest(5); request.Choices[4].Id = "A"; Assert.Contains("unique", QuestionValidator.Validate(request)); } [Fact] public void PublishedQuestionRequiresVerificationReferencesButDraftDoesNot() { var request = BuildRequest(5); request.References.Clear(); Assert.Contains("verification reference", QuestionValidator.Validate(request)); request.IsPublished = false; Assert.Null(QuestionValidator.Validate(request)); } [Fact] public void PublishedQuestionRequiresCompleteLabReferenceRanges() { var request = BuildRequest(5); request.Labs = [ new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } ]; Assert.Contains("requires a reference range", QuestionValidator.Validate(request)); request.Labs[0].Reference = "136–145 mEq/L"; Assert.Null(QuestionValidator.Validate(request)); } [Fact] public void DraftQuestionMaySaveLabWithoutReferenceRange() { var request = BuildRequest(5); request.IsPublished = false; request.Labs = [ new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" } ]; Assert.Null(QuestionValidator.Validate(request)); } [Fact] public void LabEntriesAlwaysRequireNamesAndValues() { var request = BuildRequest(5); request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }]; Assert.Contains("lab name and value", QuestionValidator.Validate(request)); request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }]; Assert.Contains("lab name and value", QuestionValidator.Validate(request)); } [Theory] [InlineData("http://example.org/guideline", "HTTPS")] [InlineData("https://example.org/guideline", null)] public void VerificationReferencesValidateUrls(string url, string? expectedError) { var request = BuildRequest(5); request.References[0].Url = url; var validation = QuestionValidator.Validate(request); if (expectedError is null) Assert.Null(validation); else Assert.Contains(expectedError, validation); } [Fact] public void VerificationReferencesRequireIsoAccessDateAndUniqueUrls() { var request = BuildRequest(5); request.References[0].AccessedOn = "July 13, 2026"; Assert.Contains("YYYY-MM-DD", QuestionValidator.Validate(request)); request = BuildRequest(5); request.References.Add(new QuestionReference { Title = "Second source", Publisher = "Publisher", Url = request.References[0].Url.ToUpperInvariant(), Supports = "Another claim", AccessedOn = "2026-07-13", Reviewer = "Reviewer" }); Assert.Contains("unique", QuestionValidator.Validate(request)); } private static Question BuildQuestion(int count) { var request = BuildRequest(count); return new Question { ExternalId = request.ExternalId, Stem = request.Stem, LeadIn = request.LeadIn, Choices = request.Choices, CorrectChoiceId = request.CorrectChoiceId, Explanation = request.Explanation, KeyTakeaway = request.KeyTakeaway, UsmlePearl = request.UsmlePearl, ReviewFollowups = request.ReviewFollowups, ChoiceExplanations = request.ChoiceExplanations, WrongAnswerFollowups = request.WrongAnswerFollowups, ChoiceSelectionPercentages = request.ChoiceSelectionPercentages, References = request.References }; } private static UpsertQuestionRequest BuildRequest(int count) { var ids = Enumerable.Range(0, count).Select(index => ((char)('A' + index)).ToString()).ToList(); var choices = ids.Select(id => new QuestionChoice { Id = id, Text = $"Choice {id}" }).ToList(); return new UpsertQuestionRequest { ExternalId = "choice-pool-test", Stem = "A complete clinical vignette.", LeadIn = "Which option is most appropriate?", Choices = choices, CorrectChoiceId = "A", Explanation = "Explanation", KeyTakeaway = "Key teaching point", UsmlePearl = "Exam pearl", ReviewFollowups = [ new QuestionReviewFollowup { Prompt = "Which statement best reinforces the concept?", Choices = [ new QuestionChoice { Id = "A", Text = "Accurate statement" }, new QuestionChoice { Id = "B", Text = "Inaccurate statement" } ], CorrectChoiceId = "A", TeachingPoint = "Review teaching point" } ], ChoiceExplanations = ids.ToDictionary(id => id, id => id == "A" ? "Correct." : "Incorrect."), WrongAnswerFollowups = ids.Where(id => id != "A").ToDictionary( id => id, id => new WrongAnswerFollowup { Prompt = "Which finding makes this less appropriate?", Choices = [ new QuestionChoice { Id = "A", Text = "Finding one" }, new QuestionChoice { Id = "B", Text = "Finding two" } ], CorrectChoiceId = "A", TeachingPoint = "Teaching point" }), ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0), Discipline = "Medicine", System = "Cardiovascular System", Difficulty = 2, References = [ new QuestionReference { Title = "Clinical guideline", Publisher = "Authoritative publisher", Url = "https://example.org/guideline", Supports = "The keyed answer and explanation.", AccessedOn = "2026-07-13", Reviewer = "Reviewer" } ], IsPublished = true }; } }