279 lines
11 KiB
C#
279 lines
11 KiB
C#
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<string, ExamSimulationResponse>
|
|
{
|
|
[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));
|
|
}
|
|
|
|
[Fact]
|
|
public void SubmittedSimulationResponsesAreLocked()
|
|
{
|
|
Assert.True(ExamSimulationStore.IsResponseLocked(
|
|
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
|
|
Assert.False(ExamSimulationStore.IsResponseLocked(
|
|
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
|
|
Assert.True(ExamSimulationStore.IsResponseLocked(
|
|
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
|
|
Assert.False(ExamSimulationStore.IsResponseLocked(
|
|
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
|
|
}
|
|
|
|
[Fact]
|
|
public void SubmittedResponseRetriesMustPreserveAnswerContent()
|
|
{
|
|
var response = new ExamSimulationResponse
|
|
{
|
|
ChoiceId = "B",
|
|
EliminatedChoiceIds = ["A"],
|
|
Flagged = true,
|
|
IsSubmitted = true
|
|
};
|
|
var same = new SaveSimulationResponseRequest
|
|
{
|
|
ChoiceId = "B",
|
|
EliminatedChoiceIds = ["A"],
|
|
Flagged = true,
|
|
IsSubmitted = true
|
|
};
|
|
var changed = new SaveSimulationResponseRequest
|
|
{
|
|
ChoiceId = "C",
|
|
EliminatedChoiceIds = ["A"],
|
|
Flagged = true,
|
|
IsSubmitted = true
|
|
};
|
|
|
|
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
|
|
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
|
|
}
|
|
|
|
private static List<ExamSimulationItem> 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<string, Question> BuildQuestions(int count) =>
|
|
Enumerable.Range(0, count).ToDictionary(
|
|
index => $"question-{index}",
|
|
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 });
|
|
}
|