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

150 lines
6.3 KiB
C#

using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DynamicQuestionTests
{
[Fact]
public void Generator_IsDeterministic_AndPreservesCorrectAnswer()
{
var blueprint = Blueprint();
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(blueprint, "same-seed", "one").Question;
var second = generator.Generate(blueprint, "same-seed", "two").Question;
Assert.Equal(first.Stem, second.Stem);
Assert.Equal(first.Choices.Select(value => value.Text), second.Choices.Select(value => value.Text));
Assert.Equal("correct", first.CorrectChoiceId);
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void SignedTokens_AreUserBound_AndRejectTampering()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DynamicQuestions:SigningKey"] = "unit-test-key" })
.Build();
var service = new DynamicQuestionTokenService(configuration);
var claims = new DynamicGenerationClaims
{
UserId = "user-one",
BlueprintExternalId = "test",
BlueprintVersion = 1,
ObjectiveId = "objective",
Seed = "seed",
Nonce = "nonce",
GeneratorVersion = 1,
ExpiresUtc = DateTime.UtcNow.AddMinutes(5)
};
var token = service.SignGeneration(claims);
Assert.Equal("seed", service.ReadGeneration(token, "user-one").Seed);
Assert.Throws<ArgumentException>(() => service.ReadGeneration(token, "user-two"));
Assert.ThrowsAny<Exception>(() => service.ReadGeneration(token + "x", "user-one"));
}
[Fact]
public void GoldGenerator_UsesCaseFamilies_AndProducesAuthoredTeachingShape()
{
var blueprint = GoldBlueprint();
var generator = new DynamicQuestionGenerator();
var generated = Enumerable.Range(0, 100)
.Select(seed => generator.Generate(blueprint, seed.ToString(), seed.ToString()))
.ToList();
Assert.Equal(3, generated.Select(value => value.CaseFamilyId).Distinct().Count());
Assert.All(generated, value =>
{
Assert.True(value.Question.Stem.Length >= 220);
Assert.Equal(5, value.Question.Choices.Count);
Assert.Equal(5, value.Question.ChoiceExplanations.Count);
Assert.Single(value.Question.ReviewFollowups);
Assert.Equal(4, value.Question.WrongAnswerFollowups.Count);
Assert.Contains("quality-standard:2", value.Question.Tags);
});
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void LegacyGeneratorVersion_RemainsDeterministic()
{
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(Blueprint(), "legacy-seed", "one", 1).Question;
var regenerated = generator.Generate(Blueprint(), "legacy-seed", "two", 1).Question;
Assert.Equal(first.Stem, regenerated.Stem);
Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id));
}
private static DynamicQuestionBlueprint Blueprint() => new()
{
ExternalId = "test",
ObjectiveId = "objective",
StemTemplate = "Patient age {{age}}.",
LeadIn = "What is correct?",
Variables = new()
{
["age"] = new DynamicVariableDefinition { Minimum = 20, Maximum = 80, Step = 1 }
},
CorrectAnswer = new DynamicAnswerTemplate { Id = "correct", TextTemplate = "Correct" },
Distractors =
[
new() { Id = "a", TextTemplate = "A" },
new() { Id = "b", TextTemplate = "B" },
new() { Id = "c", TextTemplate = "C" },
new() { Id = "d", TextTemplate = "D" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
private static DynamicQuestionBlueprint GoldBlueprint()
{
var stem = string.Join(" ", Enumerable.Repeat(
"A patient presents with a carefully correlated history, examination, vital signs, and diagnostic findings that establish the clinical context without naming the diagnosis.", 3));
return new DynamicQuestionBlueprint
{
ExternalId = "gold-test",
Version = 2,
ObjectiveId = "gold.objective",
QualityStandardVersion = 2,
CorrectConceptId = "gold.correct",
CorrectAnswer = new DynamicAnswerTemplate
{ Id = "correct", TextTemplate = "Correct action", ExplanationTemplate = "This addresses the decisive finding." },
Distractors = Enumerable.Range(1, 6).Select(index => new DynamicAnswerTemplate
{
Id = $"d{index}",
TextTemplate = $"Distractor {index}",
ExplanationTemplate = $"Distractor {index} does not address the decisive finding."
}).ToList(),
ExplanationTemplate = "A complete explanation connects the presentation to the action.",
KeyTakeaway = "Use the decisive finding.",
UsmlePearl = "Sequence urgent care correctly.",
ReviewFollowups =
[
new DynamicReviewFollowupTemplate
{
PromptTemplate = "What is decisive?",
Choices =
[
new() { Id = "yes", TextTemplate = "The unstable finding" },
new() { Id = "no", TextTemplate = "An incidental finding" }
],
CorrectChoiceId = "yes",
TeachingPointTemplate = "The unstable finding determines management."
}
],
CaseFamilies =
[
new() { Id = "family-a", StemTemplate = stem, LeadIn = "What is next?" },
new() { Id = "family-b", StemTemplate = stem + " Family B.", LeadIn = "What is next?" },
new() { Id = "family-c", StemTemplate = stem + " Family C.", LeadIn = "What is next?" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
}
}