307 lines
11 KiB
C#
307 lines
11 KiB
C#
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
|
||
};
|
||
}
|
||
}
|