Files
step3/api/QuestionsApi.Tests/CcsCaseTests.cs

257 lines
11 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 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 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 }
]
};
}