424 lines
20 KiB
C#
424 lines
20 KiB
C#
using QuestionsApi.Models;
|
|
using QuestionsApi.Services;
|
|
using System.Text.Json;
|
|
using MongoDB.Bson;
|
|
using Xunit;
|
|
|
|
namespace QuestionsApi.Tests;
|
|
|
|
public sealed class CcsCaseTests
|
|
{
|
|
[Fact]
|
|
public void NewCcsAttemptsAreTimedOnly()
|
|
{
|
|
Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest()));
|
|
Assert.Equal("new CCS attempts must be timed",
|
|
CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice }));
|
|
}
|
|
|
|
[Fact]
|
|
public void CcsBatchCommandsAreBoundedAndHomogeneous()
|
|
{
|
|
var valid = new CcsBatchCommandRequest
|
|
{
|
|
CommandId = "batch-1",
|
|
Commands =
|
|
[
|
|
new() { Type = "exam", Code = "general" },
|
|
new() { Type = "exam", Code = "cardiac" }
|
|
]
|
|
};
|
|
Assert.Null(CcsCaseValidator.ValidateBatch(valid));
|
|
valid.Commands.Add(new() { Type = "order", Code = "bmp" });
|
|
Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline()
|
|
{
|
|
Assert.Equal(18, CcsStore.ActivePhaseMinutes(20));
|
|
Assert.Equal(8, CcsStore.ActivePhaseMinutes(10));
|
|
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
|
|
Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5)));
|
|
Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void CcsHistoryCursorRoundTripsTimestampAndId()
|
|
{
|
|
var createdUtc = new DateTime(2026, 7, 19, 12, 34, 56, DateTimeKind.Utc);
|
|
var id = ObjectId.GenerateNewId().ToString();
|
|
|
|
var encoded = CcsStore.EncodeHistoryCursor(createdUtc, id);
|
|
|
|
Assert.True(CcsStore.TryDecodeHistoryCursor(encoded, out var decoded));
|
|
Assert.Equal(createdUtc, decoded!.CreatedUtc);
|
|
Assert.Equal(id, decoded.Id);
|
|
Assert.False(CcsStore.TryDecodeHistoryCursor("not-a-cursor", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void AppFeedbackValidationAcceptsSafeDiagnostics()
|
|
{
|
|
var request = new CreateAppFeedbackRequest {
|
|
Category = "bug", Details = "The next button stopped responding.",
|
|
Context = new AppFeedbackContext { Screen = "player", Platform = "Web", ViewportWidth = 1280, ViewportHeight = 720 }
|
|
};
|
|
Assert.Null(AppFeedbackValidator.Validate(request));
|
|
request.Category = "content_accuracy";
|
|
Assert.Equal("category is invalid", AppFeedbackValidator.Validate(request));
|
|
request.Category = "bug";
|
|
request.Details = "short";
|
|
Assert.Contains("details", AppFeedbackValidator.Validate(request));
|
|
}
|
|
|
|
[Fact]
|
|
public void CcsReportCategoriesAreSpecificAndStable()
|
|
{
|
|
Assert.Contains("case_progression_scoring", CcsCaseReportCategory.All);
|
|
Assert.DoesNotContain("answer_explanation", CcsCaseReportCategory.All);
|
|
Assert.Equal(6, CcsCaseReportCategory.All.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void DkaImportArtifactMatchesTheCaseSchema()
|
|
{
|
|
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/dka-emergency-001.json"));
|
|
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
|
|
Assert.NotNull(request);
|
|
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
|
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
|
|
{
|
|
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
|
|
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
|
|
Assert.NotNull(request);
|
|
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
|
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
|
var topics = request.Cases.Single().InitialInterview.Topics;
|
|
Assert.Equal([
|
|
"chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
|
|
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
|
|
], topics.Select(value => value.Id));
|
|
Assert.Equal(6, topics.Count(value => value.Required));
|
|
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
|
|
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
|
|
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
|
|
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
|
|
Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal(4, request.Cases.Single().GraphicalPresentation.InterviewRevision);
|
|
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
|
|
var exams = request.Cases.Single().Exams;
|
|
Assert.Equal([
|
|
"physical_appearance", "skin", "breasts", "lymph_nodes", "heent_neck", "chest_lungs",
|
|
"heart_cardiovascular", "abdomen", "genitalia", "rectal", "extremities_spine", "neuro_psych"
|
|
], exams.Select(value => value.Code));
|
|
Assert.All(exams, value => Assert.Equal(1, value.DurationMinutes));
|
|
Assert.Contains("mild epigastric tenderness", exams.Single(value => value.Code == "abdomen").Finding,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("brown stool", exams.Single(value => value.Code == "rectal").Finding,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains(request.Orders, value => value.Code == "h_pylori_breath");
|
|
Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple");
|
|
Assert.Contains(request.Cases.Single().OrderResponses,
|
|
value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase));
|
|
var endoscopy = request.Cases.Single().OrderResponses.Single(value => value.OrderCode == "upper_endoscopy");
|
|
Assert.Equal("endoscopy_suite", endoscopy.PerformanceLocation);
|
|
Assert.True(endoscopy.RequiresPatientVisit);
|
|
Assert.All(request.Cases.Single().OrderResponses.Where(value => value.RequiresPatientVisit),
|
|
value => Assert.False(string.IsNullOrWhiteSpace(value.PerformanceLocation)));
|
|
Assert.Contains(request.Cases.Single().ScoringCriteria,
|
|
value => value.TargetCode == "ibuprofen" && value.Points < 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void PublishedCaseRequiresReferencesAndKnownOrders()
|
|
{
|
|
var request = ValidImport();
|
|
request.Cases[0].References = [];
|
|
request.Cases[0].OrderResponses.Add(new CcsCaseOrderDefinition { OrderCode = "unknown" });
|
|
|
|
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
|
|
|
Assert.Contains(errors, value => value.Contains("reference", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(errors, value => value.Contains("unknown order code unknown"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ScoringCreditsTimelySequencedCareAndPenalizesHarm()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.ScoringCriteria =
|
|
[
|
|
new() { Id = "lab", Title = "Check electrolytes", ActionType = "order", TargetCode = "bmp", Points = 5, LatestMinute = 10, Rationale = "Check potassium." },
|
|
new() { Id = "insulin", Title = "Start insulin safely", ActionType = "order", TargetCode = "insulin", AfterTargetCode = "bmp", Points = 10, Rationale = "Start after potassium assessment." },
|
|
new() { Id = "bicarb", Title = "Avoid bicarbonate", ActionType = "order", TargetCode = "bicarb", Points = -4, Rationale = "Avoid routine bicarbonate." }
|
|
];
|
|
var attempt = new CcsAttempt { Actions =
|
|
[
|
|
new() { Type = "order", Code = "bmp", SimulatedMinute = 2 },
|
|
new() { Type = "order", Code = "insulin", SimulatedMinute = 4 },
|
|
new() { Type = "order", Code = "bicarb", SimulatedMinute = 5 }
|
|
]};
|
|
|
|
var score = CcsStore.CalculateScore(attempt, definition);
|
|
|
|
Assert.Equal(11, score.EarnedPoints);
|
|
Assert.Equal(15, score.PossiblePoints);
|
|
Assert.Equal(73.3, score.Percent);
|
|
Assert.Contains(score.Criteria, value => value.CriterionId == "bicarb" && value.Status == "harmful");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidVersionedCasePassesValidation()
|
|
{
|
|
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
|
|
}
|
|
|
|
[Fact]
|
|
public void InteractiveInterviewRequiresAReachableAcyclicGraph()
|
|
{
|
|
var request = ValidImport();
|
|
request.Cases[0].InitialInterview = ValidInterview();
|
|
Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet<string>()));
|
|
|
|
request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing";
|
|
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
|
Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase));
|
|
|
|
request = ValidImport();
|
|
request.Cases[0].InitialInterview = ValidInterview();
|
|
request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false;
|
|
request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }];
|
|
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
|
Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase));
|
|
|
|
request = ValidImport();
|
|
request.Cases[0].InitialInterview = ValidInterview();
|
|
request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true });
|
|
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
|
Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.InitialInterview = ValidInterview();
|
|
var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false };
|
|
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
|
|
|
Assert.Equal("done", attempt.InterviewNodeId);
|
|
Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds);
|
|
Assert.Equal(7, attempt.SimulatedMinute);
|
|
Assert.True(attempt.OpeningCompleted);
|
|
Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason");
|
|
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
|
|
}
|
|
|
|
[Fact]
|
|
public void LegacyCaseGetsInterviewFromExistingChartNarrative()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.InitialInterview = new();
|
|
definition.InitialChart.HistorySections =
|
|
[
|
|
new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." },
|
|
new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." }
|
|
];
|
|
|
|
var interview = CcsStore.EffectiveInterview(definition);
|
|
var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false };
|
|
|
|
Assert.Equal("opening", interview.StartNodeId);
|
|
Assert.Equal(5, interview.Nodes.Count);
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow);
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow);
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
|
Assert.Equal("complete", attempt.InterviewNodeId);
|
|
Assert.True(attempt.OpeningCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredInterviewRemainsPreferredOverGeneratedFallback()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.InitialInterview = ValidInterview();
|
|
|
|
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
|
|
}
|
|
|
|
[Fact]
|
|
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.InitialInterview = TopicInterview();
|
|
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
|
|
|
|
var opening = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
|
Assert.True(opening.SuppressPatientText);
|
|
Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id));
|
|
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
|
var chiefConcern = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
|
Assert.False(chiefConcern.SuppressPatientText);
|
|
Assert.Equal("I feel tired.", chiefConcern.PatientText);
|
|
Assert.Equal(["required_history", "optional_history"], chiefConcern.Choices.Select(value => value.Id));
|
|
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
|
|
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
|
|
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
|
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
|
|
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
|
|
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
|
Assert.True(attempt.OpeningCompleted);
|
|
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
|
|
}
|
|
|
|
[Fact]
|
|
public void TopicInterviewRepairsLegacyOpeningCompletionFlag()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
definition.InitialInterview = TopicInterview();
|
|
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true };
|
|
|
|
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
|
|
|
Assert.False(attempt.OpeningCompleted);
|
|
Assert.Equal("chief_concern", attempt.InterviewNodeId);
|
|
}
|
|
|
|
[Fact]
|
|
public void TopicInterviewRejectsPrerequisiteCycles()
|
|
{
|
|
var request = ValidImport();
|
|
request.Cases[0].InitialInterview = TopicInterview();
|
|
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
|
|
|
|
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
|
|
|
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void CaseContentComparisonIgnoresRevisionMetadata()
|
|
{
|
|
var first = ValidImport().Cases[0];
|
|
var second = ValidImport().Cases[0];
|
|
first.Id = "507f1f77bcf86cd799439011";
|
|
first.Version = 4;
|
|
first.IsCurrent = false;
|
|
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
|
|
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
|
|
|
|
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
|
second.Title = "Changed title";
|
|
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
|
}
|
|
|
|
[Fact]
|
|
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
|
{
|
|
Assert.Equal(0.5, CcsRatingStore.ExpectedScore(1000, 1000), 6);
|
|
Assert.True(CcsRatingStore.ExpectedScore(1200, 1000) > 0.75);
|
|
Assert.True(CcsRatingStore.ExpectedScore(800, 1000) < 0.25);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("medication", "medication")]
|
|
[InlineData("medication", "medications")]
|
|
[InlineData("medication", "treatment")]
|
|
[InlineData("procedure", "procedures")]
|
|
[InlineData("laboratory", "tests")]
|
|
[InlineData("diagnostic", "study")]
|
|
public void CcsOrderSearchMatchesClinicalCategories(string category, string query)
|
|
{
|
|
var order = new CcsOrderDefinition
|
|
{
|
|
Code = "order",
|
|
CanonicalName = "Unrelated canonical name",
|
|
Category = category
|
|
};
|
|
|
|
Assert.True(CcsStore.Rank(order, query) < 1000);
|
|
}
|
|
|
|
[Fact]
|
|
public void ScoringReviewIncludesExpectedAndObservedEvidence()
|
|
{
|
|
var definition = ValidImport().Cases[0];
|
|
var attempt = new CcsAttempt
|
|
{
|
|
Actions = [new() { Type = "order", Code = "bmp", SimulatedMinute = 4 }]
|
|
};
|
|
|
|
var criterion = CcsStore.CalculateScore(attempt, definition).Criteria.Single();
|
|
|
|
Assert.Equal("Check electrolytes", criterion.Title);
|
|
Assert.Contains("Bmp", criterion.ExpectedAction, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Equal("bmp", criterion.ObservedAction);
|
|
Assert.Equal(4, criterion.ObservedMinute);
|
|
}
|
|
|
|
private static CcsImportRequest ValidImport() => new()
|
|
{
|
|
Orders =
|
|
[
|
|
new() { Code = "bmp", CanonicalName = "Basic metabolic panel", Category = "laboratory" },
|
|
new() { Code = "insulin", CanonicalName = "Insulin infusion", Category = "medication" },
|
|
new() { Code = "bicarb", CanonicalName = "Sodium bicarbonate", Category = "medication" }
|
|
],
|
|
Cases =
|
|
[
|
|
new()
|
|
{
|
|
ExternalId = "ccs-test-dka-001", Title = "Diabetic ketoacidosis", System = "Endocrine System",
|
|
PresentingLocation = "emergency_department", AllowedLocations = ["emergency_department", "icu"],
|
|
IsPublished = true, RealTimeMinutes = 20, Difficulty = 2,
|
|
InitialChart = new() { PatientSummary = "Adult with diabetes", ReasonForEncounter = "Vomiting" },
|
|
OrderResponses = [new() { OrderCode = "bmp", Result = "Potassium 4.9 mEq/L" }],
|
|
ScoringCriteria = [new() { Id = "lab", Title = "Check electrolytes", ActionType = "order", TargetCode = "bmp", Points = 5 }],
|
|
References = [new() { Title = "Consensus report", Publisher = "Example Society", Url = "https://example.org/consensus", Supports = "Case management and scoring", AccessedOn = "2026-07-15", Reviewer = "Question bank editorial review" }]
|
|
}
|
|
]
|
|
};
|
|
|
|
private static CcsInitialInterview ValidInterview() => new()
|
|
{
|
|
StartNodeId = "greeting",
|
|
Nodes =
|
|
[
|
|
new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] },
|
|
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
|
|
]
|
|
};
|
|
|
|
private static CcsInitialInterview TopicInterview() => new()
|
|
{
|
|
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
|
|
Topics =
|
|
[
|
|
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
|
|
new()
|
|
{
|
|
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
|
|
PrerequisiteTopicIds = ["chief_concern"]
|
|
},
|
|
new()
|
|
{
|
|
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
|
|
PrerequisiteTopicIds = ["chief_concern"]
|
|
}
|
|
]
|
|
};
|
|
}
|