Files
step3/api/QuestionsApi.Tests/ChoicePoolTests.cs
T

307 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, int>(),
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
};
}
}