Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
030bcf6d7a | ||
|
|
25c2188264 | ||
|
|
375b3ea62f | ||
|
|
325d27eec5 | ||
|
|
7da07cb2a4 | ||
|
|
6312b45a92 | ||
|
|
07195dd24a | ||
|
|
026f0be174 | ||
|
|
9a54bfc6d6 | ||
|
|
6108011f74 | ||
|
|
07516784d2 | ||
|
|
579e1e9be4 | ||
|
|
71f33453d3 | ||
|
|
33b55753cb | ||
|
|
d349998d12 | ||
|
|
63b9d3c557 | ||
|
|
065db96211 | ||
|
|
2b25e25424 | ||
|
|
91690b2fe9 | ||
|
|
e50a97ba91 | ||
|
|
41b3092552 | ||
|
|
bab73b53ff | ||
|
|
a4115f4971 | ||
|
|
6f81e5af62 | ||
|
|
6e039f00a7 | ||
|
|
dfc224660e | ||
|
|
2c7c5dd387 | ||
|
|
ee8e8cf787 | ||
|
|
4792ff2620 | ||
|
|
38d62918d4 | ||
|
|
fa981f68ee | ||
|
|
0c330380da | ||
|
|
a0dae8e478 | ||
|
|
58dd370149 | ||
|
|
7709d16018 |
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
|
||||
echo "Dynamic question promotion failed after 3 attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
|
||||
dotnet QuestionsApi.dll promote-ccs-cases \
|
||||
--source /app/content/ccs/duodenal-ulcer-office-001.json \
|
||||
--apply
|
||||
fi
|
||||
|
||||
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
- 'tests/*.gd'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -91,6 +91,52 @@ public sealed class CcsCaseTests
|
||||
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()
|
||||
{
|
||||
@@ -177,6 +223,108 @@ public sealed class CcsCaseTests
|
||||
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()
|
||||
{
|
||||
@@ -253,4 +401,23 @@ public sealed class CcsCaseTests
|
||||
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"]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
|
||||
|
||||
COPY api/QuestionsApi/ api/QuestionsApi/
|
||||
COPY content/dynamic_questions/ content/dynamic_questions/
|
||||
COPY content/ccs/ content/ccs/
|
||||
WORKDIR /src/api/QuestionsApi
|
||||
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
|
||||
|
||||
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
|
||||
COPY --from=build /src/content/ccs ./content/ccs
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:5008 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
|
||||
@@ -64,6 +64,8 @@ public class CcsCaseRevision
|
||||
public int RealTimeMinutes { get; set; } = 20;
|
||||
public int Difficulty { get; set; } = 2;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public CcsInitialChart InitialChart { get; set; } = new();
|
||||
public CcsInitialInterview InitialInterview { get; set; } = new();
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
@@ -79,12 +81,47 @@ public class CcsInitialInterview
|
||||
{
|
||||
public string StartNodeId { get; set; } = "";
|
||||
public List<CcsDialogueNode> Nodes { get; set; } = [];
|
||||
public string OpeningPatientText { get; set; } = "";
|
||||
public List<CcsInterviewTopic> Topics { get; set; } = [];
|
||||
public string FinishChoiceText { get; set; } = "Finish the interview";
|
||||
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
|
||||
public CcsInterviewDebrief Debrief { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsInterviewTopic
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string ClinicianText { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
public List<string> PrerequisiteTopicIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsInterviewDebrief
|
||||
{
|
||||
public string LikelyDiagnosis { get; set; } = "";
|
||||
public string Summary { get; set; } = "";
|
||||
public List<string> TeachingPoints { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsGraphicalPresentation
|
||||
{
|
||||
public string SceneKey { get; set; } = "";
|
||||
public string PatientAssetKey { get; set; } = "";
|
||||
public int InterviewRevision { get; set; }
|
||||
}
|
||||
|
||||
public static class CcsExperienceModes
|
||||
{
|
||||
public const string Management = "management";
|
||||
public const string GraphicalInterview = "graphical_interview";
|
||||
}
|
||||
|
||||
public class CcsDialogueNode
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool SuppressPatientText { get; set; }
|
||||
public bool IsTerminal { get; set; }
|
||||
public List<CcsDialogueChoice> Choices { get; set; } = [];
|
||||
}
|
||||
@@ -127,6 +164,8 @@ public class CcsCaseOrderDefinition
|
||||
{
|
||||
public string OrderCode { get; set; } = "";
|
||||
public int? TurnaroundMinutes { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public string ResultSection { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
@@ -206,6 +245,8 @@ public class CcsPendingResult
|
||||
public string Category { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public int DueMinute { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Result { get; set; } = "";
|
||||
public Dictionary<string, string> VitalPatches { get; set; } = [];
|
||||
}
|
||||
@@ -234,10 +275,22 @@ public class CcsActiveOrder
|
||||
public string Frequency { get; set; } = "";
|
||||
public int OrderedMinute { get; set; }
|
||||
public int? ReportMinute { get; set; }
|
||||
public int? DueMinute { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
public string Status { get; set; } = "active";
|
||||
public bool IsPending { get; set; }
|
||||
public bool HasResult { get; set; }
|
||||
}
|
||||
|
||||
public class CcsOrderOption
|
||||
{
|
||||
public string Code { get; set; } = "";
|
||||
public int TurnaroundMinutes { get; set; }
|
||||
public string PerformanceLocation { get; set; } = "";
|
||||
public bool RequiresPatientVisit { get; set; }
|
||||
}
|
||||
|
||||
public class CcsCaseScore
|
||||
{
|
||||
public double Percent { get; set; }
|
||||
@@ -270,6 +323,7 @@ public class CcsImportResponse
|
||||
{
|
||||
public int OrdersUpserted { get; set; }
|
||||
public int CasesCreated { get; set; }
|
||||
public int CasesSkipped { get; set; }
|
||||
public List<string> Errors { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -312,6 +366,9 @@ public class CcsCaseSummary
|
||||
public int Difficulty { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool InteractiveInterviewAvailable { get; set; }
|
||||
public bool GraphicalPresentationAvailable { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsAttemptResponse
|
||||
@@ -332,20 +389,25 @@ public class CcsAttemptResponse
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public string PatientSummary { get; set; } = "";
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public string MedicalHistory { get; set; } = "";
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
|
||||
public List<CcsOrderOption> OrderOptions { get; set; } = [];
|
||||
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
|
||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public CcsInterviewStateResponse? Interview { get; set; }
|
||||
public CcsInterviewDebrief? InterviewDebrief { get; set; }
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
|
||||
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-ccs-cases")
|
||||
{
|
||||
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "export-dynamic-review")
|
||||
{
|
||||
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
|
||||
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
|
||||
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
|
||||
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
|
||||
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
|
||||
errors.Add(prefix + "experienceMode is invalid");
|
||||
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|
||||
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
|
||||
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
|
||||
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
|
||||
errors.Add(prefix + "initial patient summary and reason are required");
|
||||
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
||||
@@ -54,6 +60,9 @@ public static class CcsCaseValidator
|
||||
foreach (var response in item.OrderResponses)
|
||||
{
|
||||
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
||||
if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative");
|
||||
if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation))
|
||||
errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation");
|
||||
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
||||
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
||||
}
|
||||
@@ -73,6 +82,10 @@ public static class CcsCaseValidator
|
||||
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
|
||||
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
|
||||
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
|
||||
if (criterion.ActionType == "dialogue_choice"
|
||||
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
|
||||
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +136,55 @@ public static class CcsCaseValidator
|
||||
|
||||
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
|
||||
{
|
||||
if (interview.Topics.Count > 0)
|
||||
{
|
||||
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
|
||||
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
|
||||
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
|
||||
errors.Add(prefix + "topic interview requires openingPatientText");
|
||||
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
|
||||
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
|
||||
var topicIds = interview.Topics.Select(value => value.Id).ToList();
|
||||
if (topicIds.Any(string.IsNullOrWhiteSpace)
|
||||
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
|
||||
errors.Add(prefix + "interview topic ids must be present and unique");
|
||||
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + "finish_interview is a reserved interview topic id");
|
||||
if (!interview.Topics.Any(value => value.Required))
|
||||
errors.Add(prefix + "topic interview requires at least one required topic");
|
||||
foreach (var topic in interview.Topics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
|
||||
if (string.IsNullOrWhiteSpace(topic.PatientText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds)
|
||||
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|
||||
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
|
||||
}
|
||||
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool ResolveTopic(string topicId)
|
||||
{
|
||||
if (resolving.Contains(topicId))
|
||||
{
|
||||
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
|
||||
return false;
|
||||
}
|
||||
if (resolved.Contains(topicId)) return true;
|
||||
resolving.Add(topicId);
|
||||
if (topicMap.TryGetValue(topicId, out var topic))
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
|
||||
ResolveTopic(prerequisite);
|
||||
resolving.Remove(topicId);
|
||||
resolved.Add(topicId);
|
||||
return true;
|
||||
}
|
||||
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
|
||||
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsPromotionCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
|
||||
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
|
||||
? args[sourceIndex + 1]
|
||||
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
|
||||
var files = File.Exists(source)
|
||||
? new[] { source }
|
||||
: Directory.Exists(source)
|
||||
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
|
||||
: [];
|
||||
if (files.Length == 0)
|
||||
{
|
||||
await output.WriteLineAsync($"CCS content not found: {source}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
|
||||
var request = new CcsImportRequest();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
|
||||
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
|
||||
request.Orders.AddRange(parsed.Orders);
|
||||
request.Cases.AddRange(parsed.Cases);
|
||||
}
|
||||
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
|
||||
if (duplicateOrders.Count > 0)
|
||||
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables().Build();
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
|
||||
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
|
||||
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) await output.WriteLineAsync(error);
|
||||
return 1;
|
||||
}
|
||||
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var order in request.Orders)
|
||||
{
|
||||
order.Code = order.Code.Trim().ToLowerInvariant();
|
||||
order.UpdatedUtc = DateTime.UtcNow;
|
||||
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
|
||||
{
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await cases.UpdateOneAsync(value => value.Id == current.Id,
|
||||
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
incoming.Version = (current?.Version ?? 0) + 1;
|
||||
incoming.IsCurrent = true;
|
||||
incoming.CreatedUtc = DateTime.UtcNow;
|
||||
await cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,16 @@ public sealed class CcsStore
|
||||
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
@@ -63,7 +69,17 @@ public sealed class CcsStore
|
||||
await _cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
|
||||
}
|
||||
|
||||
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
|
||||
{
|
||||
var document = value.ToBsonDocument();
|
||||
document.Remove("_id");
|
||||
document.Remove(nameof(CcsCaseRevision.Version));
|
||||
document.Remove(nameof(CcsCaseRevision.IsCurrent));
|
||||
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
|
||||
return document;
|
||||
}
|
||||
|
||||
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
|
||||
@@ -108,6 +124,8 @@ public sealed class CcsStore
|
||||
}
|
||||
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
|
||||
var now = DateTime.UtcNow;
|
||||
var interview = EffectiveInterview(definition);
|
||||
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
|
||||
var attempt = new CcsAttempt
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -117,7 +135,7 @@ public sealed class CcsStore
|
||||
CaseVersion = definition.Version,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
InterviewNodeId = definition.InitialInterview.StartNodeId,
|
||||
InterviewNodeId = interviewStartNodeId,
|
||||
Location = definition.PresentingLocation,
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
VitalHistory =
|
||||
@@ -125,9 +143,11 @@ public sealed class CcsStore
|
||||
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
|
||||
],
|
||||
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = true,
|
||||
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
|
||||
@@ -391,6 +411,23 @@ public sealed class CcsStore
|
||||
{
|
||||
var definition = await DefinitionAsync(attempt);
|
||||
var completed = attempt.State == CcsAttemptStates.Completed;
|
||||
var optionCodes = definition.OrderResponses.Select(response => response.OrderCode).ToList();
|
||||
List<CcsOrderDefinition> optionCatalog = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? await _orders.Find(value => optionCodes.Contains(value.Code)).ToListAsync()
|
||||
: [];
|
||||
var orderOptions = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? definition.OrderResponses.Select(response =>
|
||||
{
|
||||
var catalog = optionCatalog.FirstOrDefault(value => value.Code.Equals(response.OrderCode, StringComparison.OrdinalIgnoreCase));
|
||||
return new CcsOrderOption
|
||||
{
|
||||
Code = response.OrderCode,
|
||||
TurnaroundMinutes = response.TurnaroundMinutes ?? catalog?.DefaultTurnaroundMinutes ?? 0,
|
||||
PerformanceLocation = PerformanceLocation(response, catalog, attempt.Location),
|
||||
RequiresPatientVisit = response.RequiresPatientVisit
|
||||
};
|
||||
}).ToList()
|
||||
: [];
|
||||
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
|
||||
? []
|
||||
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
|
||||
@@ -403,11 +440,19 @@ public sealed class CcsStore
|
||||
var reported = attempt.Chart.LastOrDefault(value =>
|
||||
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
|
||||
&& value.Category is not CcsChartCategories.Orders);
|
||||
var response = definition.OrderResponses.FirstOrDefault(value =>
|
||||
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
|
||||
var performanceLocation = pending?.PerformanceLocation ?? PerformanceLocation(response, order, attempt.Location);
|
||||
var requiresPatientVisit = pending?.RequiresPatientVisit ?? response?.RequiresPatientVisit == true;
|
||||
return new CcsActiveOrder
|
||||
{
|
||||
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
|
||||
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
|
||||
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
|
||||
DueMinute = pending?.DueMinute, PerformanceLocation = performanceLocation,
|
||||
RequiresPatientVisit = requiresPatientVisit,
|
||||
Status = pending is not null ? (requiresPatientVisit ? "scheduled" : "pending")
|
||||
: reported is not null ? "completed" : "active",
|
||||
IsPending = pending is not null, HasResult = reported is not null
|
||||
};
|
||||
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
|
||||
@@ -415,8 +460,10 @@ public sealed class CcsStore
|
||||
? attempt.VitalHistory
|
||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
||||
CcsInterviewStateResponse? interview = null;
|
||||
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
|
||||
var openingCompleted = attempt.OpeningCompleted ?? true;
|
||||
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
|
||||
openingCompleted = false;
|
||||
if (currentInterviewNode is not null)
|
||||
{
|
||||
interview = new CcsInterviewStateResponse
|
||||
@@ -424,7 +471,7 @@ public sealed class CcsStore
|
||||
CurrentNodeId = currentInterviewNode.Id,
|
||||
CurrentNode = currentInterviewNode,
|
||||
VisitedChoiceIds = attempt.InterviewChoiceIds,
|
||||
Completed = attempt.OpeningCompleted ?? false
|
||||
Completed = openingCompleted
|
||||
};
|
||||
}
|
||||
return new CcsAttemptResponse
|
||||
@@ -433,23 +480,31 @@ public sealed class CcsStore
|
||||
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
|
||||
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
||||
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
|
||||
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
|
||||
OpeningCompleted = openingCompleted, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
|
||||
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
|
||||
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
||||
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
||||
HistorySections = definition.InitialChart.HistorySections,
|
||||
Appearance = definition.InitialChart.Appearance,
|
||||
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? "" : definition.InitialChart.History,
|
||||
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? [] : definition.InitialChart.HistorySections,
|
||||
MedicalHistory = MedicalHistory(definition),
|
||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
ActiveOrders = activeOrders, OrderOptions = orderOptions, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
{
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
|
||||
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title,
|
||||
PerformanceLocation = value.PerformanceLocation, RequiresPatientVisit = value.RequiresPatientVisit
|
||||
}).ToList(),
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Interview = interview,
|
||||
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? definition.InitialInterview.Debrief : null,
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
References = completed ? definition.References : []
|
||||
};
|
||||
@@ -457,13 +512,25 @@ public sealed class CcsStore
|
||||
|
||||
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
|
||||
{
|
||||
if (definition.InitialInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.OpeningCompleted == true
|
||||
&& attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The initial interview is already complete.");
|
||||
// Attempts that passed through the legacy classic opening can have
|
||||
// this flag set before any graphical topic was completed.
|
||||
attempt.OpeningCompleted = false;
|
||||
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
|
||||
return;
|
||||
}
|
||||
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
|
||||
var node = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
var interview = EffectiveInterview(definition);
|
||||
var node = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The interactive interview is unavailable.");
|
||||
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
var next = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
var next = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
|
||||
@@ -484,23 +551,31 @@ public sealed class CcsStore
|
||||
{
|
||||
code = code.Trim().ToLowerInvariant();
|
||||
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
|
||||
if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
||||
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
if (definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && response is null)
|
||||
throw new InvalidOperationException("That order is not configured for this case.");
|
||||
if (response is null && catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("That order is unavailable at the current location.");
|
||||
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
|
||||
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
|
||||
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
|
||||
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
|
||||
var performanceLocation = PerformanceLocation(response, catalog, attempt.Location);
|
||||
var requiresPatientVisit = response?.RequiresPatientVisit == true;
|
||||
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
|
||||
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
|
||||
attempt.PendingResults.Add(new CcsPendingResult
|
||||
{
|
||||
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
|
||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
|
||||
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches,
|
||||
PerformanceLocation = performanceLocation, RequiresPatientVisit = requiresPatientVisit
|
||||
});
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
|
||||
SimulatedMinute = attempt.SimulatedMinute,
|
||||
Text = requiresPatientVisit
|
||||
? $"Scheduled: {catalog.CanonicalName} at {Humanize(performanceLocation)}; expected in {delay} minutes."
|
||||
: $"Ordered: {catalog.CanonicalName}"
|
||||
});
|
||||
if (delay == 0 && processImmediately) ProcessDue(attempt);
|
||||
}
|
||||
@@ -565,6 +640,13 @@ public sealed class CcsStore
|
||||
var vitalsChanged = false;
|
||||
foreach (var result in due)
|
||||
{
|
||||
if (result.RequiresPatientVisit)
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
Category = CcsChartCategories.Progress, Code = $"{result.OrderCode}_completed", Title = "Patient returned",
|
||||
SimulatedMinute = attempt.SimulatedMinute,
|
||||
Text = $"The patient completed {result.Title} at {Humanize(result.PerformanceLocation)} and returned."
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(result.Result))
|
||||
attempt.Chart.Add(new CcsChartEntry
|
||||
{
|
||||
@@ -578,6 +660,13 @@ public sealed class CcsStore
|
||||
if (vitalsChanged) SnapshotVitals(attempt);
|
||||
}
|
||||
|
||||
private static string PerformanceLocation(CcsCaseOrderDefinition? response, CcsOrderDefinition? catalog, string currentLocation)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(response?.PerformanceLocation)) return response.PerformanceLocation;
|
||||
if (catalog?.AvailableLocations.Contains(currentLocation, StringComparer.OrdinalIgnoreCase) == true) return currentLocation;
|
||||
return catalog?.AvailableLocations.FirstOrDefault() ?? currentLocation;
|
||||
}
|
||||
|
||||
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
{
|
||||
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -727,8 +816,136 @@ public sealed class CcsStore
|
||||
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
||||
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
|
||||
Difficulty = value.Difficulty, Tags = value.Tags,
|
||||
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
|
||||
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
|
||||
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
|
||||
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
|
||||
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
|
||||
};
|
||||
|
||||
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
{
|
||||
var topicInterview = definition.InitialInterview;
|
||||
if (topicInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
|
||||
};
|
||||
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
|
||||
? topicInterview.OpeningPatientText
|
||||
: topicInterview.Topics.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
|
||||
if (string.IsNullOrWhiteSpace(patientText)) return null;
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
|
||||
&& value.PrerequisiteTopicIds.All(visited.Contains))
|
||||
.Select(value => new CcsDialogueChoice
|
||||
{
|
||||
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
|
||||
}).ToList();
|
||||
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
choices.Add(new CcsDialogueChoice
|
||||
{
|
||||
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
|
||||
});
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = attempt.InterviewNodeId, PatientText = patientText,
|
||||
SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase),
|
||||
Choices = choices
|
||||
};
|
||||
}
|
||||
var interview = EffectiveInterview(definition);
|
||||
return interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
|
||||
{
|
||||
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
throw new ArgumentException("Complete the core interview topics before finishing.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add("finish_interview");
|
||||
attempt.InterviewNodeId = "complete";
|
||||
attempt.OpeningCompleted = true;
|
||||
return;
|
||||
}
|
||||
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("That interview topic has already been covered.");
|
||||
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
|
||||
throw new ArgumentException("That interview topic is not available yet.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add(topic.Id);
|
||||
attempt.InterviewNodeId = topic.Id;
|
||||
}
|
||||
|
||||
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
|
||||
&& definition.InitialInterview.Nodes.Count > 0)
|
||||
return definition.InitialInterview;
|
||||
|
||||
var narrative = definition.InitialChart.HistorySections
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.Text))
|
||||
.Select((value, index) => new
|
||||
{
|
||||
Id = $"history_{index + 1}",
|
||||
Prompt = string.IsNullOrWhiteSpace(value.Title)
|
||||
? "Can you tell me more about your history?"
|
||||
: $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?",
|
||||
value.Text
|
||||
}).ToList();
|
||||
if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History))
|
||||
narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History });
|
||||
|
||||
var nodes = new List<CcsDialogueNode>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true,
|
||||
Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview",
|
||||
Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?",
|
||||
NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete"
|
||||
}]
|
||||
}
|
||||
};
|
||||
for (var index = 0; index < narrative.Count; index++)
|
||||
{
|
||||
var next = index + 1 < narrative.Count ? narrative[index + 1] : null;
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = narrative[index].Id, PatientText = narrative[index].Text,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = next is null ? "finish_interview" : $"ask_history_{index + 2}",
|
||||
Text = next?.Prompt ?? "Is there anything else you would like me to know?",
|
||||
NextNodeId = next?.Id ?? "complete"
|
||||
}]
|
||||
});
|
||||
}
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true
|
||||
});
|
||||
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
|
||||
}
|
||||
|
||||
internal static string MedicalHistory(CcsCaseRevision definition) =>
|
||||
definition.InitialChart.HistorySections.FirstOrDefault(value =>
|
||||
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
|
||||
internal static int Rank(CcsOrderDefinition order, string query)
|
||||
{
|
||||
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cj8q5fugoyfhq"
|
||||
path="res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/medical-history.svg"
|
||||
dest_files=["res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
|
||||
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8juqb8k774q4"
|
||||
path="res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/vitals.svg"
|
||||
dest_files=["res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://crss7xdb4asxw"
|
||||
path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/computer/computer-base.png"
|
||||
dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"/>
|
||||
<line x1="8" x2="16" y1="21" y2="21"/>
|
||||
<line x1="12" x2="12" y1="17" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://diqn3ntrortw"
|
||||
path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/monitor.svg"
|
||||
dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"orders": [
|
||||
{ "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count", "hemoglobin"], "defaultTurnaroundMinutes": 20, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "cmp", "canonicalName": "Comprehensive metabolic panel", "category": "laboratory", "aliases": ["cmp", "chemistry", "bun", "creatinine"], "defaultTurnaroundMinutes": 25, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "iron_studies", "canonicalName": "Iron studies and ferritin", "category": "laboratory", "aliases": ["iron", "ferritin", "transferrin saturation"], "defaultTurnaroundMinutes": 30, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
|
||||
{ "code": "h_pylori_breath", "canonicalName": "Helicobacter pylori urea breath test", "category": "diagnostic", "aliases": ["h pylori", "urea breath test", "helicobacter"], "defaultTurnaroundMinutes": 45, "availableLocations": ["office"], "displayRoute": "Breath", "displayFrequency": "Once", "resultSection": "other_test" },
|
||||
{ "code": "upper_endoscopy", "canonicalName": "Upper endoscopy with biopsy", "category": "procedure", "aliases": ["egd", "endoscopy", "gastroscopy", "biopsy"], "defaultTurnaroundMinutes": 60, "availableLocations": ["office"], "displayRoute": "Endoscopic", "displayFrequency": "Once", "resultSection": "other_test" },
|
||||
{ "code": "omeprazole", "canonicalName": "Omeprazole acid suppression", "category": "medication", "aliases": ["ppi", "proton pump inhibitor", "acid suppression"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "Twice daily", "resultSection": "treatment" },
|
||||
{ "code": "bismuth_quadruple", "canonicalName": "Optimized bismuth quadruple therapy for 14 days", "category": "medication", "aliases": ["h pylori treatment", "bismuth", "tetracycline", "metronidazole"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" },
|
||||
{ "code": "h_pylori_test_of_cure", "canonicalName": "H. pylori test of cure after therapy", "category": "diagnostic", "aliases": ["test of cure", "repeat breath test", "eradication testing"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "At least 4 weeks after therapy", "resultSection": "other_test" },
|
||||
{ "code": "gastro_follow_up", "canonicalName": "Gastroenterology follow-up", "category": "disposition", "aliases": ["gi follow up", "gastroenterology", "follow-up"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Outpatient", "displayFrequency": "Follow-up", "resultSection": "treatment" },
|
||||
{ "code": "abdominal_ct", "canonicalName": "CT abdomen and pelvis with contrast", "category": "imaging", "aliases": ["ct abdomen", "cat scan"], "defaultTurnaroundMinutes": 60, "availableLocations": [], "displayRoute": "Imaging", "displayFrequency": "Once", "resultSection": "imaging" },
|
||||
{ "code": "clarithromycin_triple", "canonicalName": "Empiric clarithromycin triple therapy", "category": "medication", "aliases": ["clarithromycin", "triple therapy", "prevpac"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "14-day course", "resultSection": "treatment" },
|
||||
{ "code": "ibuprofen", "canonicalName": "Ibuprofen for abdominal pain", "category": "medication", "aliases": ["nsaid", "motrin", "advil"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "Oral", "displayFrequency": "As needed", "resultSection": "treatment" }
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"externalId": "ccs-gastro-duodenal-ulcer-office-001",
|
||||
"isPublished": true,
|
||||
"title": "Office Visit — 44-year-old man",
|
||||
"system": "Gastrointestinal System",
|
||||
"presentingLocation": "office",
|
||||
"realTimeMinutes": 10,
|
||||
"difficulty": 1,
|
||||
"tags": ["history taking", "dyspepsia", "duodenal ulcer", "Helicobacter pylori"],
|
||||
"experienceMode": "graphical_interview",
|
||||
"graphicalPresentation": {
|
||||
"sceneKey": "doctor_office",
|
||||
"patientAssetKey": "patient_44_white_male",
|
||||
"interviewRevision": 4
|
||||
},
|
||||
"initialChart": {
|
||||
"patientSummary": "44-year-old man attending an office visit",
|
||||
"reasonForEncounter": "The reason for the visit has not yet been elicited.",
|
||||
"appearance": "He is seated comfortably, attentive, and in no acute distress.",
|
||||
"history": "Several weeks of intermittent lightheadedness and fatigue accompany three weeks of sleep-disrupting, burning epigastric discomfort rated 4/10. Food and antacids provide temporary relief. He reports belching, fullness, and occasional black stool without frank blood. He has hyperlipidemia and no recent sick contacts, travel, or antibiotic use.",
|
||||
"historySections": [
|
||||
{ "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." },
|
||||
{ "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Burning, non-radiating epigastric discomfort has been present for three weeks, is rated 4/10, is unaffected by position or movement, and improves temporarily after food or antacids." },
|
||||
{ "code": "associated_symptoms", "title": "Associated symptoms", "text": "Belching, fullness, sleep disruption, and occasional black stool without frank blood." },
|
||||
{ "code": "past_medical_history", "title": "Past medical history", "text": "Hyperlipidemia" },
|
||||
{ "code": "exposure_history", "title": "Exposure history", "text": "No recent sick contacts, travel, or antibiotic use." }
|
||||
],
|
||||
"vitals": {
|
||||
"temperature": "36.8 C (98.2 F)",
|
||||
"bloodPressure": "122/76 mm Hg",
|
||||
"pulse": "76/min",
|
||||
"respirations": "14/min",
|
||||
"oxygenSaturation": "99% on room air"
|
||||
},
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z",
|
||||
"initialOrderCodes": []
|
||||
},
|
||||
"initialInterview": {
|
||||
"openingPatientText": "Good morning, doctor.",
|
||||
"finishChoiceText": "Thank you. I have enough history for now.",
|
||||
"closingPatientText": "Thank you, doctor. I hope we can figure out what has been going on.",
|
||||
"topics": [
|
||||
{
|
||||
"id": "chief_concern",
|
||||
"clinicianText": "Good morning. What brings you in today?",
|
||||
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "dizziness_character",
|
||||
"clinicianText": "Tell me more about the dizziness. Does the room spin, and have you fainted?",
|
||||
"patientText": "The room does not spin. It is more like I might black out for a second when I get up, but I have never actually fainted. I have had no weakness, numbness, trouble speaking, chest pain, or racing heartbeat.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
},
|
||||
{
|
||||
"id": "fatigue_course",
|
||||
"clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?",
|
||||
"patientText": "My energy is down by the afternoon. My appetite is about the same, but lately a burning discomfort high in my abdomen sometimes wakes me at night.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
},
|
||||
{
|
||||
"id": "abdominal_character",
|
||||
"clinicianText": "Tell me more about the abdominal discomfort—where is it, what does it feel like, and how long has it been happening?",
|
||||
"patientText": "It started about three weeks ago. It is a burning pain in the upper middle part of my abdomen. It does not spread anywhere, I would rate it about a four out of ten, and changing position or moving does not make it better or worse.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["fatigue_course"]
|
||||
},
|
||||
{
|
||||
"id": "relieving_factors",
|
||||
"clinicianText": "Does anything make the discomfort better or worse?",
|
||||
"patientText": "Eating usually settles it for a while, and antacids help too. Position and movement do not seem to change it.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "associated_gi",
|
||||
"clinicianText": "Have you noticed belching, fullness, vomiting, or any blood or change in the color of your stool?",
|
||||
"patientText": "I have been belching more and sometimes feel unusually full. I have not vomited or seen red blood, but my stool has looked black on occasion.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "exposure_history",
|
||||
"clinicianText": "Have you had any sick contacts, recent travel, or antibiotic use?",
|
||||
"patientText": "No. I have not been around anyone sick, traveled recently, or taken antibiotics.",
|
||||
"required": true,
|
||||
"prerequisiteTopicIds": ["abdominal_character"]
|
||||
},
|
||||
{
|
||||
"id": "past_medical_history",
|
||||
"clinicianText": "Do you have any past medical conditions?",
|
||||
"patientText": "A few years ago, my doctor told me my cholesterol was high. Other than that, I have not been diagnosed with any major medical problems.",
|
||||
"required": false,
|
||||
"prerequisiteTopicIds": ["chief_concern"]
|
||||
}
|
||||
],
|
||||
"debrief": {
|
||||
"likelyDiagnosis": "Peptic ulcer disease with possible intermittent upper gastrointestinal bleeding",
|
||||
"summary": "The history identifies orthostatic lightheadedness and fatigue accompanying burning epigastric pain relieved by food and antacids, with occasional black stool concerning for intermittent upper gastrointestinal bleeding.",
|
||||
"teachingPoints": [
|
||||
"Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.",
|
||||
"Characterize epigastric pain by timing, quality, severity, radiation, triggers, and relieving factors.",
|
||||
"Ask specifically about melena or hematemesis because occult or intermittent bleeding can present with fatigue and lightheadedness."
|
||||
]
|
||||
}
|
||||
},
|
||||
"allowedLocations": ["office"],
|
||||
"exams": [
|
||||
{ "code": "physical_appearance", "name": "Physical Appearance", "durationMinutes": 1, "finding": "The patient is alert, cooperative, and in no acute distress. He appears well nourished." },
|
||||
{ "code": "skin", "name": "Skin", "durationMinutes": 1, "finding": "Skin is warm and dry without rash, jaundice, bruising, or visible pallor." },
|
||||
{ "code": "breasts", "name": "Breasts", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, examination shows no masses, tenderness, skin changes, or nipple discharge." },
|
||||
{ "code": "lymph_nodes", "name": "Lymph Nodes", "durationMinutes": 1, "finding": "No cervical, supraclavicular, axillary, or inguinal lymphadenopathy is present." },
|
||||
{ "code": "heent_neck", "name": "HEENT / Neck", "durationMinutes": 1, "finding": "Conjunctivae and oral mucosa are normal. Oropharynx is clear, neck is supple, and there is no thyromegaly or jugular venous distention." },
|
||||
{ "code": "chest_lungs", "name": "Chest / Lungs", "durationMinutes": 1, "finding": "Breathing is unlabored. Lungs are clear to auscultation bilaterally without wheezes, crackles, or rhonchi." },
|
||||
{ "code": "heart_cardiovascular", "name": "Heart / Cardiovascular", "durationMinutes": 1, "finding": "Heart has a regular rate and rhythm with normal S1 and S2 and no murmurs, rubs, or gallops. Peripheral pulses are symmetric." },
|
||||
{ "code": "abdomen", "name": "Abdomen", "durationMinutes": 1, "finding": "The abdomen is soft and nondistended with mild epigastric tenderness. There is no guarding, rebound tenderness, palpable mass, or hepatosplenomegaly. Bowel sounds are normal." },
|
||||
{ "code": "genitalia", "name": "Genitalia", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, external genital examination is normal without lesions, masses, tenderness, or hernia." },
|
||||
{ "code": "rectal", "name": "Rectal", "durationMinutes": 1, "finding": "With the patient's consent and a chaperone present, rectal tone is normal and no mass or tenderness is present. Brown stool without gross blood is noted." },
|
||||
{ "code": "extremities_spine", "name": "Extremities / Spine", "durationMinutes": 1, "finding": "Extremities have full range of motion without edema, clubbing, cyanosis, or joint swelling. The spine is nontender." },
|
||||
{ "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." }
|
||||
],
|
||||
"orderResponses": [
|
||||
{ "orderCode": "cbc", "turnaroundMinutes": 20, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" },
|
||||
{ "orderCode": "cmp", "turnaroundMinutes": 25, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" },
|
||||
{ "orderCode": "iron_studies", "turnaroundMinutes": 30, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" },
|
||||
{ "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "performanceLocation": "office_diagnostic_area", "requiresPatientVisit": true, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" },
|
||||
{ "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "performanceLocation": "endoscopy_suite", "requiresPatientVisit": true, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" },
|
||||
{ "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" },
|
||||
{ "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" },
|
||||
{ "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" },
|
||||
{ "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" },
|
||||
{ "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "performanceLocation": "imaging_center", "requiresPatientVisit": true, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" },
|
||||
{ "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" },
|
||||
{ "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" }
|
||||
],
|
||||
"events": [],
|
||||
"scoringCriteria": [
|
||||
{ "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." },
|
||||
{ "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." },
|
||||
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." },
|
||||
{ "id": "characterize_abdominal_pain", "title": "Characterize the abdominal discomfort", "actionType": "dialogue_choice", "targetCode": "abdominal_character", "points": 15, "rationale": "Location, quality, duration, severity, and radiation help characterize the source of pain." },
|
||||
{ "id": "identify_relief", "title": "Identify relieving and aggravating factors", "actionType": "dialogue_choice", "targetCode": "relieving_factors", "points": 10, "rationale": "Temporary relief with food or antacids supports an acid-related pattern." },
|
||||
{ "id": "screen_gi_bleeding", "title": "Screen for gastrointestinal bleeding", "actionType": "dialogue_choice", "targetCode": "associated_gi", "points": 15, "rationale": "Occasional black stool may represent melena and requires recognition." },
|
||||
{ "id": "review_exposures", "title": "Review relevant exposures", "actionType": "dialogue_choice", "targetCode": "exposure_history", "points": 5, "rationale": "Recent illness, travel, and antibiotic exposure help refine the gastrointestinal differential." },
|
||||
{ "id": "assess_appearance", "title": "Assess physical appearance", "actionType": "exam", "targetCode": "physical_appearance", "points": 5, "rationale": "General appearance helps assess acuity and physiologic compromise." },
|
||||
{ "id": "examine_skin", "title": "Examine the skin", "actionType": "exam", "targetCode": "skin", "points": 5, "rationale": "Skin examination screens for pallor, jaundice, bruising, and other systemic clues." },
|
||||
{ "id": "examine_lungs", "title": "Examine the chest and lungs", "actionType": "exam", "targetCode": "chest_lungs", "points": 5, "rationale": "A cardiopulmonary examination helps evaluate associated fatigue and lightheadedness." },
|
||||
{ "id": "examine_heart", "title": "Examine the cardiovascular system", "actionType": "exam", "targetCode": "heart_cardiovascular", "points": 5, "rationale": "Cardiovascular examination helps assess presyncope and hemodynamic status." },
|
||||
{ "id": "examine_abdomen", "title": "Examine the abdomen", "actionType": "exam", "targetCode": "abdomen", "points": 15, "rationale": "Focused abdominal examination identifies epigastric tenderness and screens for peritoneal signs or masses." },
|
||||
{ "id": "perform_rectal_exam", "title": "Perform a rectal examination", "actionType": "exam", "targetCode": "rectal", "points": 10, "rationale": "Rectal examination can assess for current gross gastrointestinal bleeding when the history includes black stool." },
|
||||
{ "id": "perform_neurologic_exam", "title": "Perform a neurologic examination", "actionType": "exam", "targetCode": "neuro_psych", "points": 5, "rationale": "A focused neurologic examination helps evaluate lightheadedness and exclude focal deficits." },
|
||||
{ "id": "check_cbc", "title": "Assess anemia with a CBC", "actionType": "order", "targetCode": "cbc", "points": 12, "rationale": "Fatigue, lightheadedness, and intermittent melena warrant assessment for blood-loss anemia." },
|
||||
{ "id": "check_iron", "title": "Confirm iron deficiency", "actionType": "order", "targetCode": "iron_studies", "points": 6, "rationale": "Iron studies characterize the microcytic anemia." },
|
||||
{ "id": "test_h_pylori", "title": "Test for H. pylori", "actionType": "order", "targetCode": "h_pylori_breath", "points": 12, "rationale": "H. pylori testing identifies a treatable cause of peptic ulcer disease." },
|
||||
{ "id": "perform_egd", "title": "Evaluate suspected bleeding with upper endoscopy", "actionType": "order", "targetCode": "upper_endoscopy", "points": 12, "rationale": "Endoscopy confirms the ulcer and evaluates bleeding risk." },
|
||||
{ "id": "start_ppi", "title": "Begin proton-pump inhibitor therapy", "actionType": "order", "targetCode": "omeprazole", "points": 8, "rationale": "Acid suppression promotes ulcer healing." },
|
||||
{ "id": "eradicate_h_pylori", "title": "Treat H. pylori appropriately", "actionType": "order", "targetCode": "bismuth_quadruple", "points": 15, "afterTargetCode": "h_pylori_breath", "rationale": "Optimized bismuth quadruple therapy is an appropriate empiric eradication regimen." },
|
||||
{ "id": "confirm_eradication", "title": "Arrange H. pylori test of cure", "actionType": "order", "targetCode": "h_pylori_test_of_cure", "points": 8, "afterTargetCode": "bismuth_quadruple", "rationale": "Eradication should be confirmed after treatment." },
|
||||
{ "id": "avoid_clarithromycin", "title": "Avoid unsupported clarithromycin triple therapy", "actionType": "order", "targetCode": "clarithromycin_triple", "points": -8, "rationale": "Empiric clarithromycin therapy should be avoided without demonstrated susceptibility." },
|
||||
{ "id": "avoid_nsaid", "title": "Avoid NSAIDs", "actionType": "order", "targetCode": "ibuprofen", "points": -12, "rationale": "NSAIDs may worsen ulcer injury and gastrointestinal bleeding." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"title": "Peptic Ulcer Disease",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/topics/peptic-ulcer-disease/",
|
||||
"supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
},
|
||||
{
|
||||
"title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/",
|
||||
"supports": "H. pylori treatment principles and confirmation of eradication",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"validateOnly": false
|
||||
}
|
||||
@@ -4,6 +4,10 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user.
|
||||
|
||||
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
|
||||
|
||||
Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management.
|
||||
|
||||
UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision.
|
||||
|
||||
## Case structure
|
||||
|
||||
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=8 format=3]
|
||||
[gd_scene load_steps=14 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"]
|
||||
@@ -6,6 +6,12 @@
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png" id="4_patient"]
|
||||
[ext_resource type="Shader" path="res://shaders/ccs_cases_2/patient_idle.gdshader" id="5_patient_fit"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/chat-bubble.png" id="6_chat_bubble"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/computer/computer-base.png" id="7_computer"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/vitals.svg" id="10_vitals"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/medical-history.svg" id="11_history"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/clipboard-list.svg" id="12_orders"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
|
||||
shader = ExtResource("5_patient_fit")
|
||||
@@ -103,15 +109,17 @@ antialiased = true
|
||||
|
||||
[node name="DialogueBubble" type="Control" parent="."]
|
||||
z_index = 30
|
||||
clip_contents = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.51
|
||||
anchor_top = 0.055
|
||||
anchor_right = 0.51
|
||||
anchor_bottom = 0.055
|
||||
offset_right = 420.0
|
||||
offset_bottom = 140.0
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.53
|
||||
anchor_top = 0.04
|
||||
anchor_right = 0.53
|
||||
anchor_bottom = 0.04
|
||||
offset_right = 650.0
|
||||
offset_bottom = 220.0
|
||||
mouse_default_cursor_shape = 2
|
||||
focus_mode = 2
|
||||
|
||||
[node name="Background" type="TextureRect" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
@@ -126,33 +134,395 @@ texture = ExtResource("6_chat_bubble")
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="Text" type="Label" parent="DialogueBubble"]
|
||||
[node name="Text" type="RichTextLabel" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 76.0
|
||||
offset_left = 96.0
|
||||
offset_top = 31.0
|
||||
offset_right = -48.0
|
||||
offset_bottom = -43.0
|
||||
offset_right = -64.0
|
||||
offset_bottom = -64.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Hello, doctor."
|
||||
fit_content = false
|
||||
scroll_active = false
|
||||
autowrap_mode = 2
|
||||
visible_characters_behavior = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoices" type="VBoxContainer" parent="."]
|
||||
[node name="ContinueHint" type="Label" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -330.0
|
||||
offset_top = -55.0
|
||||
offset_right = -90.0
|
||||
offset_bottom = -27.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.12, 0.22, 0.28, 0.82)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Click to continue"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoiceArea" type="Control" parent="."]
|
||||
z_index = 29
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.5
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -210.0
|
||||
offset_top = -206.0
|
||||
offset_right = 210.0
|
||||
offset_left = 18.0
|
||||
offset_top = -194.0
|
||||
offset_right = -18.0
|
||||
offset_bottom = -4.0
|
||||
theme_override_constants/separation = -8
|
||||
|
||||
[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 42.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Previous"
|
||||
|
||||
[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(100, 38)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1)
|
||||
theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9)
|
||||
theme_override_constants/shadow_offset_x = 1
|
||||
theme_override_constants/shadow_offset_y = 2
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Page 1 of 2"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Next"
|
||||
|
||||
[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 46.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 1
|
||||
|
||||
[node name="ComputerLauncher" type="Button" parent="."]
|
||||
z_index = 40
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 24.0
|
||||
offset_right = 188.0
|
||||
offset_bottom = 78.0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Open simulated computer"
|
||||
icon = ExtResource("8_monitor")
|
||||
icon_max_width = 25
|
||||
expand_icon = true
|
||||
text = "Computer"
|
||||
|
||||
[node name="ExaminationLauncher" type="Button" parent="."]
|
||||
visible = false
|
||||
z_index = 40
|
||||
layout_mode = 0
|
||||
offset_left = 202.0
|
||||
offset_top = 24.0
|
||||
offset_right = 394.0
|
||||
offset_bottom = 78.0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Perform physical examination"
|
||||
icon = ExtResource("10_vitals")
|
||||
icon_max_width = 25
|
||||
expand_icon = true
|
||||
text = "Examination"
|
||||
|
||||
[node name="ComputerOverlay" type="Control" parent="."]
|
||||
visible = false
|
||||
z_index = 100
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
|
||||
[node name="Dimmer" type="ColorRect" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 0
|
||||
color = Color(0.015, 0.025, 0.04, 0.86)
|
||||
|
||||
[node name="Screen" type="TextureRect" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
texture = ExtResource("7_computer")
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="VitalsLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 32.0
|
||||
offset_right = 142.0
|
||||
offset_bottom = 138.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("10_vitals")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/VitalsLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Vitals"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="MedicalHistoryLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 154.0
|
||||
offset_right = 166.0
|
||||
offset_bottom = 260.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("11_history")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="OrdersLauncher" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 0
|
||||
offset_left = 30.0
|
||||
offset_top = 276.0
|
||||
offset_right = 142.0
|
||||
offset_bottom = 382.0
|
||||
mouse_default_cursor_shape = 2
|
||||
icon = ExtResource("12_orders")
|
||||
icon_max_width = 58
|
||||
expand_icon = true
|
||||
icon_alignment = 1
|
||||
vertical_icon_alignment = 0
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/OrdersLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Orders"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Label" type="Label" parent="ComputerOverlay/MedicalHistoryLauncher"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = -34.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.96, 0.98, 1, 1)
|
||||
theme_override_colors/font_outline_color = Color(0.01, 0.04, 0.07, 0.95)
|
||||
theme_override_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 16
|
||||
text = "Medical History"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -360.0
|
||||
offset_top = -270.0
|
||||
offset_right = 360.0
|
||||
offset_bottom = 270.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/VitalsWindow"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 32
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 32
|
||||
theme_override_constants/margin_bottom = 28
|
||||
|
||||
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 13
|
||||
|
||||
[node name="Title" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Vitals"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay/VitalsWindow/Margin/Content/Header"]
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close vital signs"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 24
|
||||
expand_icon = true
|
||||
|
||||
[node name="Divider" type="HSeparator" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Rows" type="GridContainer" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/h_separation = 40
|
||||
theme_override_constants/v_separation = 12
|
||||
columns = 2
|
||||
|
||||
[node name="Empty" type="Label" parent="ComputerOverlay/VitalsWindow/Margin/Content"]
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "No nurse vital signs are available."
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="MedicalHistoryWindow" type="PanelContainer" parent="ComputerOverlay"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -330.0
|
||||
offset_top = -210.0
|
||||
offset_right = 330.0
|
||||
offset_bottom = 210.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/MedicalHistoryWindow"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 32
|
||||
theme_override_constants/margin_top = 24
|
||||
theme_override_constants/margin_right = 32
|
||||
theme_override_constants/margin_bottom = 28
|
||||
|
||||
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 20
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 13
|
||||
|
||||
[node name="Title" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
theme_override_font_sizes/font_size = 30
|
||||
text = "Medical History"
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
|
||||
custom_minimum_size = Vector2(48, 48)
|
||||
layout_mode = 2
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close medical history"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 24
|
||||
expand_icon = true
|
||||
|
||||
[node name="Divider" type="HSeparator" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SectionTitle" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.18, 0.25, 0.31, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Past Medical History"
|
||||
|
||||
[node name="Text" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.04, 0.09, 0.13, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "No past medical history is available."
|
||||
|
||||
[node name="Close" type="Button" parent="ComputerOverlay"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -88.0
|
||||
offset_top = 20.0
|
||||
offset_right = -24.0
|
||||
offset_bottom = 84.0
|
||||
grow_horizontal = 0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Close computer"
|
||||
icon = ExtResource("9_close")
|
||||
icon_max_width = 30
|
||||
expand_icon = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,9 @@ extends Control
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
|
||||
const DESIGN_SIZE := Vector2(1500.0, 800.0)
|
||||
const DESIGN_SIZE := Vector2(1600.0, 900.0)
|
||||
const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001"
|
||||
const FOCUSED_INTERVIEW_REVISION := 4
|
||||
|
||||
var main
|
||||
var stage_root: Control
|
||||
@@ -16,6 +18,8 @@ var current_view := ""
|
||||
var cases_loaded := false
|
||||
var active_loaded := false
|
||||
var pending_action := ""
|
||||
var pending_exam_code := ""
|
||||
var restart_case_external_id := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -39,10 +43,15 @@ func _connect_service() -> void:
|
||||
CCSService.attempt_changed.connect(_on_attempt_changed)
|
||||
if not CCSService.request_failed.is_connected(_on_request_failed):
|
||||
CCSService.request_failed.connect(_on_request_failed)
|
||||
if not CCSService.orders_changed.is_connected(_on_orders_changed):
|
||||
CCSService.orders_changed.connect(_on_orders_changed)
|
||||
if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned):
|
||||
CCSService.attempt_abandoned.connect(_on_attempt_abandoned)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CCSService.launch_origin = "ccs_cases_2"
|
||||
restart_case_external_id = ""
|
||||
cases_loaded = false
|
||||
active_loaded = false
|
||||
pending_action = "loading"
|
||||
@@ -52,12 +61,27 @@ func on_show(_payload: Variant = null) -> void:
|
||||
|
||||
|
||||
func on_hide() -> void:
|
||||
restart_case_external_id = ""
|
||||
_clear_active_scene()
|
||||
|
||||
|
||||
func set_pause_menu_open(open: bool) -> void:
|
||||
if active_scene != null and active_scene.has_method("set_dialogue_input_enabled"):
|
||||
active_scene.set_dialogue_input_enabled(not open)
|
||||
|
||||
|
||||
func close_transient_overlay() -> bool:
|
||||
if active_scene != null and active_scene.has_method("close_transient_overlay"):
|
||||
return bool(active_scene.close_transient_overlay())
|
||||
return false
|
||||
|
||||
|
||||
func compatible_cases(cases: Array) -> Array:
|
||||
return cases.filter(func(item: Variant) -> bool:
|
||||
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
|
||||
return typeof(item) == TYPE_DICTIONARY \
|
||||
and bool(item.get("interactiveInterviewAvailable", false)) \
|
||||
and bool(item.get("graphicalPresentationAvailable", false)) \
|
||||
and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office")
|
||||
|
||||
|
||||
func _on_cases_changed() -> void:
|
||||
@@ -67,16 +91,49 @@ func _on_cases_changed() -> void:
|
||||
|
||||
func _on_attempt_changed() -> void:
|
||||
active_loaded = true
|
||||
var completed_action := pending_action
|
||||
var completed_exam_code := pending_exam_code
|
||||
pending_action = ""
|
||||
pending_exam_code = ""
|
||||
if completed_action == "physical_exam" and active_scene != null \
|
||||
and current_view == "doctor_office":
|
||||
_refresh_exam_result(CCSService.attempt, completed_exam_code)
|
||||
return
|
||||
if completed_action in ["orders", "cancel_order", "advance", "enter_final_orders"] \
|
||||
and active_scene != null and current_view == "doctor_office":
|
||||
_refresh_case_workspace(CCSService.attempt)
|
||||
if completed_action != "enter_final_orders":
|
||||
active_scene.show_order_action_complete(completed_action)
|
||||
return
|
||||
_render_state()
|
||||
|
||||
|
||||
func _refresh_exam_result(attempt: Dictionary, exam_code: String) -> void:
|
||||
_refresh_case_workspace(attempt)
|
||||
active_scene.show_exam_result(attempt.get("chart", []), exam_code)
|
||||
|
||||
|
||||
func _refresh_case_workspace(attempt: Dictionary) -> void:
|
||||
active_scene.configure_presentation(
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")), attempt.get("chart", []))
|
||||
active_scene.configure_examinations(attempt.get("availableExams", []), attempt.get("chart", []))
|
||||
active_scene.configure_orders(attempt)
|
||||
|
||||
|
||||
func _render_state() -> void:
|
||||
if not visible:
|
||||
return
|
||||
if not active_loaded:
|
||||
return
|
||||
var attempt: Dictionary = CCSService.attempt
|
||||
if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \
|
||||
and str(attempt.get("experienceMode", "")) == "graphical_interview":
|
||||
_render_debrief(attempt)
|
||||
return
|
||||
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
|
||||
_render_attempt(attempt)
|
||||
elif cases_loaded and active_loaded:
|
||||
@@ -84,21 +141,25 @@ func _render_state() -> void:
|
||||
|
||||
|
||||
func _render_attempt(attempt: Dictionary) -> void:
|
||||
if str(attempt.get("state", "")) != "active":
|
||||
_handoff_to_classic_player()
|
||||
if str(attempt.get("state", "")) not in ["active", "final_orders"]:
|
||||
_show_unsupported_attempt("This active case is not available in the graphical interview yet.")
|
||||
return
|
||||
var interview: Dictionary = attempt.get("interview", {})
|
||||
if interview.is_empty():
|
||||
_handoff_to_classic_player()
|
||||
_show_unsupported_attempt("This active case does not include a graphical dialogue interview.")
|
||||
return
|
||||
var node: Dictionary = interview.get("currentNode", {})
|
||||
if node.is_empty():
|
||||
_handoff_to_classic_player()
|
||||
_show_unsupported_attempt("The graphical interview could not load its current dialogue.")
|
||||
return
|
||||
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||
_handoff_to_classic_player()
|
||||
if _is_retired_interview_revision(attempt, node):
|
||||
_restart_with_current_interview(attempt)
|
||||
return
|
||||
_show_doctor_office(node)
|
||||
if str(attempt.get("experienceMode", "management")) != "graphical_interview" \
|
||||
and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||
_show_unsupported_attempt("This case has progressed beyond the graphical interview.")
|
||||
return
|
||||
_show_doctor_office(node, attempt)
|
||||
|
||||
|
||||
func _build_selection_view() -> void:
|
||||
@@ -113,7 +174,7 @@ func _build_selection_view() -> void:
|
||||
heading.add_theme_font_size_override("font_size", 28)
|
||||
selection_view.add_child(heading)
|
||||
var description := Label.new()
|
||||
description.text = "Begin a timed patient interview, then continue into clinical management."
|
||||
description.text = "Interview the patient by selecting the questions you want to ask."
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Brand.apply_font(description)
|
||||
description.add_theme_font_size_override("font_size", 16)
|
||||
@@ -151,12 +212,18 @@ func _show_selection(message: String = "") -> void:
|
||||
retry_button.visible = false
|
||||
|
||||
|
||||
func _show_unsupported_attempt(message: String) -> void:
|
||||
_show_selection(message)
|
||||
_clear_children(case_list)
|
||||
status_label.text += " Finish or abandon it from CCS Cases before starting another graphical interview."
|
||||
|
||||
|
||||
func _render_case_selection() -> void:
|
||||
_show_selection()
|
||||
_clear_children(case_list)
|
||||
var available := compatible_cases(CCSService.cases)
|
||||
if available.is_empty():
|
||||
status_label.text = "No published CCS cases currently include an interactive interview."
|
||||
status_label.text = "No published CCS cases currently have a supported graphical interview."
|
||||
return
|
||||
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
|
||||
retry_button.visible = false
|
||||
@@ -183,14 +250,31 @@ func _start_case(case_external_id: String) -> void:
|
||||
CCSService.create_attempt(case_external_id, "timed")
|
||||
|
||||
|
||||
func _show_doctor_office(node: Dictionary) -> void:
|
||||
func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
|
||||
if active_scene == null:
|
||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
||||
active_scene.choice_selected.connect(_submit_dialogue_choice)
|
||||
active_scene.interview_continue_requested.connect(_handoff_to_classic_player)
|
||||
active_scene.physical_exam_selected.connect(_submit_physical_exam)
|
||||
active_scene.physical_exam_finish_requested.connect(_finish_physical_examination)
|
||||
active_scene.order_search_requested.connect(_search_orders)
|
||||
active_scene.orders_submit_requested.connect(_submit_orders)
|
||||
active_scene.order_cancel_requested.connect(_cancel_order)
|
||||
active_scene.time_advance_requested.connect(_advance_time)
|
||||
active_scene.final_orders_submit_requested.connect(_submit_final_orders)
|
||||
active_scene.debrief_return_requested.connect(_return_to_cases)
|
||||
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
||||
selection_view.visible = false
|
||||
stage_root.visible = true
|
||||
active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management")))
|
||||
active_scene.configure_presentation(
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")), attempt.get("chart", []))
|
||||
active_scene.configure_examinations(attempt.get("availableExams", []), attempt.get("chart", []))
|
||||
active_scene.configure_interview(node)
|
||||
active_scene.configure_orders(attempt)
|
||||
active_scene.set_choices_pending(not pending_action.is_empty())
|
||||
|
||||
|
||||
@@ -202,10 +286,122 @@ func _submit_dialogue_choice(choice_id: String) -> void:
|
||||
CCSService.send_dialogue_choice(choice_id)
|
||||
|
||||
|
||||
func _handoff_to_classic_player() -> void:
|
||||
current_view = "handoff"
|
||||
if main != null:
|
||||
main.show_screen("ccs_player")
|
||||
func _submit_physical_exam(exam_code: String) -> void:
|
||||
if not pending_action.is_empty() or exam_code.is_empty():
|
||||
return
|
||||
pending_action = "physical_exam"
|
||||
pending_exam_code = exam_code
|
||||
active_scene.set_physical_exam_pending(exam_code)
|
||||
CCSService.send_command("exam", exam_code)
|
||||
|
||||
|
||||
func _finish_physical_examination() -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "enter_final_orders"
|
||||
active_scene.set_physical_exam_pending("finishing_case")
|
||||
CCSService.send_command("end_case")
|
||||
|
||||
|
||||
func _search_orders(query: String) -> void:
|
||||
CCSService.search_orders(query)
|
||||
|
||||
|
||||
func _on_orders_changed() -> void:
|
||||
if active_scene != null and active_scene.has_method("render_order_search"):
|
||||
active_scene.render_order_search(CCSService.orders)
|
||||
|
||||
|
||||
func _submit_orders(codes: Array) -> void:
|
||||
if not pending_action.is_empty() or codes.is_empty():
|
||||
return
|
||||
pending_action = "orders"
|
||||
active_scene.set_orders_pending(true, "Submitting orders...")
|
||||
var commands: Array = []
|
||||
for code in codes:
|
||||
commands.append({"type": "order", "code": str(code), "minutes": 0})
|
||||
CCSService.send_batch(commands)
|
||||
|
||||
|
||||
func _cancel_order(code: String) -> void:
|
||||
if not pending_action.is_empty() or code.is_empty():
|
||||
return
|
||||
pending_action = "cancel_order"
|
||||
active_scene.set_orders_pending(true, "Cancelling order...")
|
||||
CCSService.send_batch([{"type": "cancel_order", "code": code, "minutes": 0}])
|
||||
|
||||
|
||||
func _advance_time(minutes: int) -> void:
|
||||
if not pending_action.is_empty() or minutes <= 0:
|
||||
return
|
||||
pending_action = "advance"
|
||||
active_scene.set_orders_pending(true, "Obtaining results...")
|
||||
CCSService.send_command("advance", "", minutes)
|
||||
|
||||
|
||||
func _submit_final_orders() -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "submit_final_orders"
|
||||
active_scene.set_orders_pending(true, "Submitting final orders...")
|
||||
CCSService.send_command("exit_case")
|
||||
|
||||
|
||||
func _render_debrief(attempt: Dictionary) -> void:
|
||||
if active_scene == null:
|
||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
||||
active_scene.debrief_return_requested.connect(_return_to_cases)
|
||||
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
||||
selection_view.visible = false
|
||||
stage_root.visible = true
|
||||
active_scene.configure_presentation(
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")), attempt.get("chart", []))
|
||||
active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
|
||||
current_view = "interview_debrief"
|
||||
|
||||
|
||||
func _return_to_cases() -> void:
|
||||
CCSService.attempt = {}
|
||||
pending_action = ""
|
||||
_render_case_selection()
|
||||
|
||||
|
||||
func _repeat_interview() -> void:
|
||||
var case_external_id := str(CCSService.attempt.get("caseExternalId", ""))
|
||||
if case_external_id.is_empty() or not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "creating"
|
||||
CCSService.create_attempt(case_external_id, "timed")
|
||||
|
||||
|
||||
func _is_retired_interview_revision(attempt: Dictionary, _node: Dictionary) -> bool:
|
||||
if str(attempt.get("caseExternalId", "")) != FOCUSED_INTERVIEW_CASE_ID:
|
||||
return false
|
||||
var presentation: Dictionary = attempt.get("graphicalPresentation", {})
|
||||
return int(presentation.get("interviewRevision", 0)) < FOCUSED_INTERVIEW_REVISION
|
||||
|
||||
|
||||
func _restart_with_current_interview(attempt: Dictionary) -> void:
|
||||
if pending_action == "refreshing_interview":
|
||||
return
|
||||
restart_case_external_id = str(attempt.get("caseExternalId", ""))
|
||||
pending_action = "refreshing_interview"
|
||||
_show_selection("Updating this case to the latest interactive examination...")
|
||||
CCSService.abandon()
|
||||
|
||||
|
||||
func _on_attempt_abandoned() -> void:
|
||||
if restart_case_external_id.is_empty():
|
||||
return
|
||||
var case_external_id := restart_case_external_id
|
||||
restart_case_external_id = ""
|
||||
pending_action = "creating"
|
||||
status_label.text = "Starting the updated interview..."
|
||||
CCSService.create_attempt(case_external_id, "timed")
|
||||
|
||||
|
||||
func _on_request_failed(message: String) -> void:
|
||||
@@ -217,8 +413,19 @@ func _on_request_failed(message: String) -> void:
|
||||
status_label.text = "An active case was found. Loading it now..."
|
||||
CCSService.load_active()
|
||||
return
|
||||
if failed_action == "refreshing_interview":
|
||||
restart_case_external_id = ""
|
||||
status_label.text = "The updated interview could not be started. %s" % message
|
||||
retry_button.visible = true
|
||||
return
|
||||
if active_scene != null:
|
||||
active_scene.set_choices_pending(false)
|
||||
if failed_action == "physical_exam":
|
||||
pending_exam_code = ""
|
||||
active_scene.show_physical_exam_error(message)
|
||||
else:
|
||||
active_scene.set_choices_pending(false)
|
||||
if active_scene.has_method("set_orders_pending"):
|
||||
active_scene.set_orders_pending(false, message)
|
||||
else:
|
||||
status_label.text = message
|
||||
retry_button.visible = failed_action == "loading"
|
||||
@@ -264,6 +471,6 @@ func _fit_stage() -> void:
|
||||
var available := size
|
||||
if available.x <= 0.0 or available.y <= 0.0:
|
||||
return
|
||||
var fit_scale := minf(available.x / DESIGN_SIZE.x, available.y / DESIGN_SIZE.y)
|
||||
stage_root.scale = Vector2.ONE * fit_scale
|
||||
stage_root.position = (available - DESIGN_SIZE * fit_scale) * 0.5
|
||||
stage_root.scale = Vector2.ONE
|
||||
stage_root.position = Vector2.ZERO
|
||||
stage_root.size = available
|
||||
|
||||
@@ -435,6 +435,7 @@ func on_show(payload: Variant = null) -> void:
|
||||
|
||||
func on_hide() -> void:
|
||||
timer.stop()
|
||||
opening_active = false
|
||||
_dismiss_dialogs()
|
||||
|
||||
|
||||
@@ -458,6 +459,12 @@ func _ccs_dialogs() -> Array[Window]:
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
# This screen stays subscribed to the shared CCS attempt while hidden.
|
||||
# Never allow its Window-based dialogs to appear over CCS Cases 2.
|
||||
if not visible:
|
||||
opening_active = false
|
||||
_dismiss_dialogs()
|
||||
return
|
||||
var attempt: Dictionary = CCSService.attempt
|
||||
if attempt.is_empty():
|
||||
return
|
||||
|
||||
+32
-6
@@ -126,11 +126,11 @@ func _apply_screen_margins(screen_name: String) -> void:
|
||||
if screens_margin == null or screen_name.is_empty():
|
||||
return
|
||||
var is_fullscreen_study := screen_name == "player"
|
||||
var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44))
|
||||
var margin := 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 44))
|
||||
screens_margin.add_theme_constant_override("margin_left", margin)
|
||||
screens_margin.add_theme_constant_override("margin_right", margin)
|
||||
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
|
||||
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else (12 if is_fullscreen_study else 28))
|
||||
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
|
||||
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else 28))
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
@@ -582,7 +582,7 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
|
||||
|
||||
|
||||
func _screen_uses_shell(screen_name: String) -> bool:
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "ccs_cases_2"]
|
||||
|
||||
|
||||
func _update_shell_visibility(screen_name: String) -> void:
|
||||
@@ -699,15 +699,29 @@ func _input(event: InputEvent) -> void:
|
||||
if not AuthState.is_logged_in or current_screen_name == "login":
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
if _close_current_transient_overlay():
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if drawer_open:
|
||||
_close_drawer()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
|
||||
if _screen_uses_pause_menu(current_screen_name):
|
||||
_toggle_app_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _close_current_transient_overlay() -> bool:
|
||||
var screen: Control = screens.get(current_screen_name)
|
||||
if screen != null and screen.has_method("close_transient_overlay"):
|
||||
return bool(screen.close_transient_overlay())
|
||||
return false
|
||||
|
||||
|
||||
func _screen_uses_pause_menu(screen_name: String) -> bool:
|
||||
return screen_name in ["player", "ccs_player", "ccs_cases_2", "flashcards_study"]
|
||||
|
||||
|
||||
func _refresh_header_controls() -> void:
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
@@ -875,13 +889,15 @@ func _toggle_app_menu() -> void:
|
||||
_close_app_menu()
|
||||
else:
|
||||
UISoundService.play_sound("open")
|
||||
_set_current_screen_pause_menu_open(true)
|
||||
app_menu.visible = true
|
||||
app_menu.size = app_menu.custom_minimum_size
|
||||
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
|
||||
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
|
||||
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "ccs_cases_2", "flashcards_study"]
|
||||
if menu_exit_button.visible:
|
||||
menu_exit_button.text = (
|
||||
"Abandon CCS Case" if current_screen_name == "ccs_player"
|
||||
else "Exit CCS Interview" if current_screen_name == "ccs_cases_2"
|
||||
else "Exit Study Session" if current_screen_name == "flashcards_study"
|
||||
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
|
||||
else "Exit Run")
|
||||
@@ -892,9 +908,16 @@ func _toggle_app_menu() -> void:
|
||||
func _close_app_menu() -> void:
|
||||
if app_menu != null:
|
||||
app_menu.visible = false
|
||||
_set_current_screen_pause_menu_open(false)
|
||||
BlockSessionService.resume_current_question_timer()
|
||||
|
||||
|
||||
func _set_current_screen_pause_menu_open(open: bool) -> void:
|
||||
var screen = screens.get(current_screen_name)
|
||||
if screen != null and screen.has_method("set_pause_menu_open"):
|
||||
screen.set_pause_menu_open(open)
|
||||
|
||||
|
||||
func _navigate_from_pause(target_screen: String) -> void:
|
||||
var return_screen := current_screen_name
|
||||
show_screen(target_screen, {"return_screen": return_screen})
|
||||
@@ -911,6 +934,9 @@ func exit_current_run() -> void:
|
||||
if current_screen_name == "flashcards_study":
|
||||
show_screen("flashcards_hub")
|
||||
return
|
||||
if current_screen_name == "ccs_cases_2":
|
||||
show_screen("dashboard")
|
||||
return
|
||||
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||
BlockSessionService.finish_block()
|
||||
elif BlockSessionService.session_kind == "simulation":
|
||||
|
||||
@@ -23,6 +23,16 @@ func _run() -> void:
|
||||
var nav_button := main_screen.sidebar.find_child("NavCcsCases2", true, false) as Button
|
||||
_assert(nav_button != null and nav_button.visible, "UAT should expose a CCS Cases 2 navigation item")
|
||||
_assert(main_screen.screens.has("ccs_cases_2"), "UAT should register the graphical CCS screen")
|
||||
_assert(not main_screen._screen_uses_shell("ccs_cases_2"), "CCS Cases 2 should hide the application shell")
|
||||
main_screen._apply_screen_margins("ccs_cases_2")
|
||||
_assert(main_screen.screens_margin.get_theme_constant("margin_left") == 0, "fullscreen CCS should remove horizontal screen margins")
|
||||
_assert(main_screen.screens_margin.get_theme_constant("margin_top") == 0, "fullscreen CCS should remove vertical screen margins")
|
||||
main_screen.current_screen_name = "ccs_cases_2"
|
||||
_assert(main_screen._screen_uses_pause_menu("ccs_cases_2"), "CCS Cases 2 should route Escape to the pause menu")
|
||||
main_screen._toggle_app_menu()
|
||||
_assert(main_screen.app_menu.visible, "the pause menu should open over CCS Cases 2")
|
||||
_assert(main_screen.menu_exit_button.visible and main_screen.menu_exit_button.text == "Exit CCS Interview", "the CCS pause menu should expose a clear exit action")
|
||||
main_screen._close_app_menu()
|
||||
main_screen.queue_free()
|
||||
await process_frame
|
||||
ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
|
||||
@@ -40,7 +50,11 @@ func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
service.cases = [
|
||||
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
|
||||
{
|
||||
"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2,
|
||||
"realTimeMinutes": 20, "interactiveInterviewAvailable": true, "graphicalPresentationAvailable": true,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}
|
||||
},
|
||||
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
|
||||
]
|
||||
service.attempt = {}
|
||||
@@ -50,22 +64,278 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection")
|
||||
_assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection")
|
||||
var retired_attempt := {
|
||||
"caseExternalId": "ccs-gastro-duodenal-ulcer-office-001",
|
||||
"graphicalPresentation": {"interviewRevision": 1}
|
||||
}
|
||||
_assert(screen._is_retired_interview_revision(retired_attempt, {
|
||||
"id": "fatigue_course", "patientText": "I feel tired.", "choices": []
|
||||
}), "an older authored interview revision should trigger a restart")
|
||||
retired_attempt.graphicalPresentation.interviewRevision = 4
|
||||
_assert(not screen._is_retired_interview_revision(retired_attempt, {}),
|
||||
"the current authored interview revision should remain active")
|
||||
|
||||
service.attempt = _sample_attempt(false)
|
||||
screen._render_state()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office")
|
||||
_assert(screen.active_scene.dialogue_text.text == "Hello, doctor.", "the patient bubble should use the server-provided current node")
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "the opening should wait for a clinician dialogue selection")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices")
|
||||
_assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio")
|
||||
_assert(screen.active_scene.examination_launcher.visible, "Examination should be available while questions are visible")
|
||||
_assert(screen.active_scene.computer_launcher.visible, "the doctor office should expose the simulated computer launcher")
|
||||
_assert(screen.active_scene.computer_launcher.icon.resource_path.ends_with("monitor.svg"), "the computer launcher should use the monitor icon")
|
||||
screen.active_scene.computer_launcher.pressed.emit()
|
||||
_assert(screen.active_scene.is_computer_open(), "the computer launcher should open the simulated desktop")
|
||||
_assert(not screen.active_scene.computer_launcher.visible, "the launcher should hide while the computer is open")
|
||||
_assert(screen.active_scene.computer_overlay.get_node("Screen").texture.resource_path.ends_with("computer-base.png"), "the overlay should render the supplied computer base")
|
||||
_assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars")
|
||||
_assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app")
|
||||
_assert(screen.active_scene.medical_history_launcher.visible, "the computer desktop should expose a Medical History app")
|
||||
_assert(screen.active_scene.orders_launcher.visible, "the computer desktop should expose an Orders app")
|
||||
_assert(screen.active_scene.vitals_launcher.text.is_empty() and screen.active_scene.medical_history_launcher.text.is_empty(), "desktop shortcuts should place labels below centered icons")
|
||||
_assert(screen.active_scene.vitals_launcher.get_node("Label").text == "Vitals" and screen.active_scene.medical_history_launcher.get_node("Label").text == "Medical History", "desktop shortcut labels should remain readable")
|
||||
_assert(screen.active_scene.vitals_launcher.get_theme_stylebox("normal") is StyleBoxEmpty and screen.active_scene.medical_history_launcher.get_theme_stylebox("normal") is StyleBoxEmpty, "desktop shortcuts should not render permanent button tiles")
|
||||
_assert(screen.active_scene.medical_history_launcher.position.y > screen.active_scene.vitals_launcher.get_rect().end.y, "desktop shortcuts should form a vertical icon column")
|
||||
_assert(screen.active_scene.vitals_launcher.icon.get_width() >= 58 and screen.active_scene.medical_history_launcher.icon.get_width() >= 58, "desktop shortcuts should use high-resolution vector imports")
|
||||
screen.active_scene.orders_launcher.pressed.emit()
|
||||
_assert(screen.active_scene.orders_window.visible and screen.active_scene.orders_tabs.get_tab_count() == 3, "Orders should open a three-tab clinical workspace")
|
||||
screen.active_scene._queue_live_order_search("bl")
|
||||
_assert(not screen.active_scene.order_search_timer.is_stopped(), "order typing should schedule a debounced live search")
|
||||
screen.active_scene.order_search_timer.stop()
|
||||
screen.active_scene.current_order_location = "office"
|
||||
screen.active_scene.render_order_search([
|
||||
{"code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "availableLocations": []},
|
||||
{"code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "availableLocations": ["emergency_department", "icu"]}
|
||||
])
|
||||
_assert(screen.active_scene.orders_search_results.get_child_count() == 2, "order search should keep unavailable catalog entries visible")
|
||||
_assert(not (screen.active_scene.orders_search_results.get_child(0) as Button).disabled, "case-authored orders should remain selectable across performance locations")
|
||||
_assert((screen.active_scene.orders_search_results.get_child(1) as Button).disabled, "orders not authored for this case should explain their unavailability")
|
||||
(screen.active_scene.orders_search_results.get_child(0) as Button).pressed.emit()
|
||||
_assert(screen.active_scene.staged_order_codes == ["cbc"] and not screen.active_scene.orders_confirm_button.disabled, "catalog results should stage for confirmation")
|
||||
screen.active_scene.configure_orders({
|
||||
"state": "active", "activeOrderCodes": ["cbc"],
|
||||
"orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}],
|
||||
"activeOrders": [{"code": "cbc", "name": "Complete blood count", "status": "completed", "performanceLocation": "outpatient_laboratory"}],
|
||||
"chart": [{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}]
|
||||
})
|
||||
_assert(screen.active_scene.staged_order_codes.is_empty(), "confirmed active orders should leave the staged list")
|
||||
_assert(screen.active_scene.orders_results.text.contains("Hemoglobin 10.4"), "returned results should render in the Orders results tab")
|
||||
screen.active_scene.configure_orders({
|
||||
"state": "active", "activeOrderCodes": ["cbc"], "orderOptions": [], "activeOrders": [],
|
||||
"chart": [
|
||||
{"category": "progress", "code": "cbc_completed", "simulatedMinute": 20, "text": "The patient completed Complete blood count at Outpatient Laboratory and returned."},
|
||||
{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}
|
||||
]
|
||||
})
|
||||
_assert(screen.active_scene.patient_update_panel.visible and screen.active_scene.patient_update_text.text.contains("returned"), "completed off-site work should produce an in-scene patient update")
|
||||
screen.active_scene.patient_update_panel.visible = false
|
||||
screen.active_scene.show_order_action_complete("orders")
|
||||
_assert(screen.active_scene.orders_tabs.current_tab == 1 and screen.active_scene.orders_status.text.begins_with("Orders confirmed"), "successful submission should explain completion and open Active Orders")
|
||||
screen.active_scene.show_order_action_complete("advance")
|
||||
_assert(screen.active_scene.orders_tabs.current_tab == 2, "obtaining results should open the Results tab")
|
||||
screen.active_scene.close_orders()
|
||||
screen.active_scene.configure_orders({"state": "final_orders", "activeOrderCodes": [], "activeOrders": [], "chart": [], "finalOrdersDeadlineUtc": "2026-08-14T12:00:00Z"})
|
||||
_assert(screen.active_scene.is_computer_open() and screen.active_scene.orders_window.visible, "final orders should automatically open the Orders app")
|
||||
_assert(screen.active_scene.orders_submit_final_button.visible and not screen.active_scene.orders_submit_final_button.disabled \
|
||||
and not screen.active_scene.orders_advance_button.visible, "final orders should expose an enabled submission while locking time advancement")
|
||||
_assert(screen.active_scene.orders_status.text.contains("Pending results do not need to return"), "final review should clearly explain pending orders")
|
||||
_assert(screen.active_scene.close_orders(), "the Orders app should remain closable during final review")
|
||||
_assert(screen.active_scene.close_computer(), "the computer should remain closable during final review")
|
||||
screen.active_scene.open_computer()
|
||||
screen.active_scene.open_orders()
|
||||
screen.active_scene.final_orders_mode = false
|
||||
screen.active_scene.close_orders()
|
||||
screen.active_scene.close_computer()
|
||||
screen.active_scene.open_computer()
|
||||
screen.active_scene.vitals_launcher.pressed.emit()
|
||||
_assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window")
|
||||
_assert(screen.active_scene.vitals_header.get_node("Title").text == "Vitals", "the vital window should use the concise Vitals heading")
|
||||
_assert(_vital_values(screen.active_scene) == ["36.8 C (98.2 F)", "76/min", "14/min", "122 mm Hg", "76 mm Hg", "99% on room air"], "the Vitals app should render the active attempt data in clinical order")
|
||||
_assert(_vital_values_fit(screen.active_scene), "vital values should retain a readable column without character-by-character wrapping")
|
||||
screen.active_scene.medical_history_launcher.pressed.emit()
|
||||
_assert(screen.active_scene.is_vitals_open() and screen.active_scene.is_medical_history_open(), "both chart windows should be able to remain open")
|
||||
_assert(screen.active_scene.medical_history_text.text == "Hyperlipidemia", "Medical History should render the case-authored past medical history")
|
||||
_assert(screen.active_scene.medical_history_window.size.y <= 350.0, "Medical History should size closely to its tabbed content (size %s, minimum %s)" % [screen.active_scene.medical_history_window.size, screen.active_scene.medical_history_window.get_combined_minimum_size()])
|
||||
_assert(screen.active_scene.physical_exam_tab_button.text == "Physical Exam", "Medical History should include a Physical Exam tab")
|
||||
_assert(screen.active_scene.medical_history_exam_text.text == "No physical examinations have been completed.", "Physical Exam should begin with an empty state")
|
||||
_assert(screen.active_scene.medical_history_exam_text.bbcode_enabled, "Physical Exam headings should render as rich text")
|
||||
_assert(screen.active_scene.medical_history_exam_text.get_parent() is ScrollContainer, "Physical Exam overflow should use an internal scroller")
|
||||
_assert(screen.active_scene.vitals_window.size.y <= 410.0, "Vitals should size to its rendered measurement rows")
|
||||
_assert(screen.active_scene.medical_history_window.z_index > screen.active_scene.vitals_window.z_index, "the most recently opened chart window should come to the front")
|
||||
var original_history_position: Vector2 = screen.active_scene.medical_history_window.position
|
||||
screen.active_scene._set_medical_history_window_position(Vector2(90.0, 70.0))
|
||||
var moved_history_position: Vector2 = screen.active_scene.medical_history_window.position
|
||||
_assert(not moved_history_position.is_equal_approx(original_history_position) \
|
||||
and moved_history_position.x >= 0.0 and moved_history_position.y >= 0.0,
|
||||
"Medical History should use the shared draggable-window behavior")
|
||||
_assert(screen.close_transient_overlay(), "the first Escape should close the topmost Medical History app")
|
||||
_assert(not screen.active_scene.is_medical_history_open() and screen.active_scene.is_vitals_open(), "closing Medical History should leave Vitals open")
|
||||
screen.active_scene._set_vitals_window_position(Vector2(75.0, 55.0))
|
||||
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(75.0, 55.0)), "the Vitals window should support repositioning by its header")
|
||||
screen.active_scene._set_vitals_window_position(Vector2(-500.0, 5000.0))
|
||||
var max_vitals_position: Vector2 = screen.active_scene.size - screen.active_scene.vitals_window.size
|
||||
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(0.0, max_vitals_position.y)), "dragging should keep the Vitals window inside the computer viewport")
|
||||
_assert(screen.close_transient_overlay(), "the second Escape should close the Vitals app")
|
||||
_assert(not screen.active_scene.is_vitals_open() and screen.active_scene.is_computer_open(), "closing Vitals should return to the computer desktop")
|
||||
_assert(screen.close_transient_overlay(), "the third Escape should close the computer")
|
||||
_assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office")
|
||||
_assert(not screen.close_transient_overlay(), "the fourth Escape should be available to open the pause menu")
|
||||
screen.active_scene.open_computer()
|
||||
screen.active_scene.open_vitals()
|
||||
screen.active_scene.open_medical_history()
|
||||
screen.active_scene.computer_close_button.pressed.emit()
|
||||
_assert(not screen.active_scene.is_computer_open() and not screen.active_scene.is_vitals_open() \
|
||||
and not screen.active_scene.is_medical_history_open(),
|
||||
"the computer close button should close every child chart window")
|
||||
_assert(is_equal_approx(screen.DESIGN_SIZE.x / screen.DESIGN_SIZE.y, 16.0 / 9.0), "the graphical CCS stage should use a true 16:9 design canvas")
|
||||
_assert(screen.stage_root.size.is_equal_approx(screen.size), "the graphical stage should fill the available viewport")
|
||||
_assert(screen.stage_root.position.is_equal_approx(Vector2.ZERO), "the graphical stage should not introduce letterbox margins")
|
||||
var stale_graphical_attempt := _sample_attempt(false)
|
||||
stale_graphical_attempt.experienceMode = "graphical_interview"
|
||||
stale_graphical_attempt.openingCompleted = true
|
||||
service.attempt = stale_graphical_attempt
|
||||
screen._render_state()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "doctor_office", "legacy completion flags should not block a graphical interview")
|
||||
service.attempt = _sample_branching_attempt()
|
||||
screen._render_state()
|
||||
await process_frame
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "a selected topic should display the patient's response")
|
||||
_assert(not screen.active_scene.examination_launcher.visible, "Examination should be unavailable while the patient is speaking")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the next choices should wait until the patient finishes speaking")
|
||||
_assert(screen.active_scene.dialogue_pages.size() > 1, "long patient responses should split into readable dialogue pages")
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the first patient response page should fit inside its speech bubble")
|
||||
_assert(is_equal_approx(screen.active_scene.dialogue_text.offset_left, 96.0), "patient dialogue should retain the increased left inset")
|
||||
_assert(_bubble_matches_content(screen.active_scene), "the speech bubble should size itself to the wrapped response page")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.visible, "the continuation prompt should remain visible while text is typing")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.text == "Click to continue", "every dialogue page should use supported continuation wording")
|
||||
_assert(screen.active_scene.dialogue_text.visible_characters_behavior == TextServer.VC_CHARS_AFTER_SHAPING, "typewriter text should wrap before glyphs are revealed")
|
||||
var wrapped_line_count: int = screen.active_scene.dialogue_text.get_line_count()
|
||||
screen.active_scene.open_computer()
|
||||
var computer_blocked_page: int = screen.active_scene.dialogue_page_index
|
||||
var computer_click := _left_click(screen.active_scene.get_global_rect().get_center())
|
||||
screen.active_scene._input(computer_click)
|
||||
_assert(screen.active_scene.dialogue_page_index == computer_blocked_page and not screen.active_scene.dialogue_reveal_complete, "the open computer should block scene-wide dialogue clicks")
|
||||
screen.active_scene.computer_close_button.pressed.emit()
|
||||
_assert(not screen.active_scene.is_computer_open(), "the computer close button should return to the interview")
|
||||
var outside_click := _left_click(screen.active_scene.get_global_rect().end + Vector2(50.0, 50.0))
|
||||
screen.active_scene._input(outside_click)
|
||||
_assert(not screen.active_scene.dialogue_reveal_complete, "clicks outside the doctor scene should not affect dialogue")
|
||||
var scene_click := _left_click(screen.active_scene.get_global_rect().get_center())
|
||||
screen.set_pause_menu_open(true)
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(not screen.active_scene.dialogue_reveal_complete, "dialogue should ignore scene clicks while the Escape menu is open")
|
||||
screen.set_pause_menu_open(false)
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page")
|
||||
_assert(screen.active_scene.dialogue_text.get_line_count() == wrapped_line_count, "revealing typewriter text should not shift words between lines")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.visible, "completed dialogue pages should invite the learner to continue")
|
||||
await create_timer(1.35).timeout
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "patient dialogue should remain until the learner advances it")
|
||||
_assert(not screen.active_scene.choice_container.visible, "questions must not appear on a timer")
|
||||
while screen.active_scene.dialogue_page_index < screen.active_scene.dialogue_pages.size() - 1:
|
||||
screen.active_scene._input(scene_click)
|
||||
await process_frame
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "every patient response page should fit inside its speech bubble")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene._input(scene_click)
|
||||
await process_frame
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace patient dialogue after the final learner click")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices")
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "scene clicks should not affect explicit question controls")
|
||||
_assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls")
|
||||
_assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_0", "topic_1", "topic_2", "topic_3"], "the first page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "first-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(1)
|
||||
await process_frame
|
||||
_assert(screen.active_scene.choice_page == 1, "Next should advance to the second question page")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the second question page should contain the remaining choices")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_4", "topic_5", "topic_6", "topic_7"], "the second page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "second-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(-1)
|
||||
_assert(screen.active_scene.choice_page == 0, "Previous should return to the first question page")
|
||||
|
||||
service.attempt = _sample_attempt(true)
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene.advance_dialogue()
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
|
||||
screen.active_scene.interview_continue_requested.emit()
|
||||
_assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player")
|
||||
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
|
||||
var terminal_choice := screen.active_scene.choice_container.get_child(0) as Control
|
||||
_assert(terminal_choice.size.x <= 360.0, "a single terminal choice should remain a compact centered card")
|
||||
_assert((terminal_choice.get_child(0) as Label).text == "Review Examinations",
|
||||
"terminal dialogue should lead into the graphical physical examination")
|
||||
terminal_choice.pressed.emit()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "doctor_office", "the examination should remain inside the active interview")
|
||||
_assert(screen.active_scene.is_physical_exam_visible(), "the physical examination panel should be visible")
|
||||
_assert(screen.active_scene.physical_exam_grid.columns == 2, "exam components should use two responsive columns")
|
||||
_assert(screen.active_scene.physical_exam_grid.get_child_count() == 12, "all twelve examination components should be available")
|
||||
_assert(not screen.active_scene.physical_exam_finish_button.disabled, "Finish Case should remain available before every exam is complete")
|
||||
screen.active_scene.set_physical_exam_pending("abdomen")
|
||||
screen.pending_action = "physical_exam"
|
||||
screen.pending_exam_code = "abdomen"
|
||||
_assert(screen.active_scene.physical_exam_finish_button.disabled, "case completion should pause while an exam command is pending")
|
||||
_assert(screen.active_scene.physical_exam_buttons.values().all(func(button): return button.disabled),
|
||||
"exam controls should disable while one component is submitted")
|
||||
var examined_attempt := _sample_attempt(true)
|
||||
examined_attempt.chart = [{"code": "abdomen", "title": "Abdomen", "text": "Mild epigastric tenderness."}]
|
||||
service.attempt = examined_attempt
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "doctor_office", "attempt refreshes should preserve the interview phase")
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "an examination result should not replay the current patient dialogue")
|
||||
_assert(screen.active_scene.physical_exam_finding_text.text == "Mild epigastric tenderness.", "the returned chart finding should be displayed")
|
||||
_assert(screen.active_scene.physical_exam_result_button.visible, "a completed exam should present a focused result card")
|
||||
var result_dialogue_page: int = screen.active_scene.dialogue_page_index
|
||||
screen.active_scene._input(_left_click(screen.active_scene.physical_exam_result_button.get_global_rect().get_center()))
|
||||
_assert(screen.active_scene.physical_exam_result_button.visible \
|
||||
and screen.active_scene.dialogue_page_index == result_dialogue_page,
|
||||
"scene-wide dialogue input should not consume clicks while an examination result is open")
|
||||
screen.active_scene.physical_exam_result_button.pressed.emit()
|
||||
_assert(screen.active_scene.is_physical_exam_visible(), "terminal exam results should return to the exam selector")
|
||||
screen.active_scene.open_computer()
|
||||
screen.active_scene.open_medical_history()
|
||||
screen.active_scene.physical_exam_tab_button.pressed.emit()
|
||||
_assert(screen.active_scene.medical_history_exam_text.text.contains("Mild epigastric tenderness."), "completed findings should be stored in the Physical Exam tab")
|
||||
_assert(screen.active_scene.medical_history_window.size.y <= 650.0, "the Physical Exam chart should remain bounded while findings scroll")
|
||||
|
||||
service.attempt = {"id": "classic-attempt", "state": "active", "openingCompleted": false, "interview": {}}
|
||||
host.shown_screen = ""
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "case_selection", "unsupported active attempts should remain outside the classic player")
|
||||
_assert(host.shown_screen.is_empty(), "CCS Cases 2 should not trigger classic opening overlays")
|
||||
|
||||
service.attempt = _sample_graphical_debrief()
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2")
|
||||
_assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief")
|
||||
_assert(not screen.active_scene.computer_launcher.visible, "the computer launcher should stay hidden during the interview debrief")
|
||||
|
||||
var sizing_scene = load("res://scenes/ccs_cases_2/doctor_office.tscn").instantiate()
|
||||
host.add_child(sizing_scene)
|
||||
await process_frame
|
||||
sizing_scene.show_patient_dialogue("Short response.")
|
||||
await process_frame
|
||||
var short_height: float = sizing_scene.dialogue_bubble.size.y
|
||||
_assert(is_equal_approx(short_height, sizing_scene.DIALOGUE_BUBBLE_MIN_HEIGHT), "short dialogue should use the minimum bubble height")
|
||||
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should fit inside a minimum-height bubble")
|
||||
_assert(sizing_scene.dialogue_continue_hint.position.y - sizing_scene.dialogue_text.get_rect().end.y >= 8.0, "the continuation hint should have visible padding above it")
|
||||
sizing_scene.show_patient_dialogue("Line one explains the symptom.\nLine two adds timing.\nLine three adds context.\nLine four concludes.")
|
||||
await process_frame
|
||||
_assert(sizing_scene.dialogue_bubble.size.y > short_height, "multi-line dialogue should increase the bubble height")
|
||||
_assert(sizing_scene.dialogue_bubble.size.y <= sizing_scene.DIALOGUE_BUBBLE_MAX_HEIGHT, "dialogue bubble growth should remain bounded")
|
||||
_assert(_bubble_matches_content(sizing_scene), "the expanded bubble should contain all wrapped text")
|
||||
sizing_scene.complete_dialogue_reveal()
|
||||
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should remain inside the resized bubble")
|
||||
sizing_scene.queue_free()
|
||||
|
||||
screen.on_hide()
|
||||
await process_frame
|
||||
@@ -83,15 +353,144 @@ func _sample_attempt(completed: bool) -> Dictionary:
|
||||
"id": "done" if completed else "greeting",
|
||||
"patientText": "Okay, doctor." if completed else "Hello, doctor.",
|
||||
"isTerminal": completed,
|
||||
"suppressPatientText": not completed,
|
||||
"choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}]
|
||||
}
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 1,
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 4},
|
||||
"medicalHistory": "Hyperlipidemia",
|
||||
"vitals": {
|
||||
"temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",
|
||||
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
|
||||
},
|
||||
"openingCompleted": completed,
|
||||
"experienceMode": "graphical_interview",
|
||||
"availableExams": _sample_exams(), "chart": [],
|
||||
"orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}],
|
||||
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
||||
}
|
||||
|
||||
|
||||
func _sample_exams() -> Array:
|
||||
var definitions := [
|
||||
["physical_appearance", "Physical Appearance"], ["skin", "Skin"], ["breasts", "Breasts"],
|
||||
["lymph_nodes", "Lymph Nodes"], ["heent_neck", "HEENT / Neck"], ["chest_lungs", "Chest / Lungs"],
|
||||
["heart_cardiovascular", "Heart / Cardiovascular"], ["abdomen", "Abdomen"], ["genitalia", "Genitalia"],
|
||||
["rectal", "Rectal"], ["extremities_spine", "Extremities / Spine"], ["neuro_psych", "Neuro / Psych"]
|
||||
]
|
||||
var exams: Array = []
|
||||
for definition in definitions:
|
||||
exams.append({"code": definition[0], "name": definition[1], "durationMinutes": 1})
|
||||
return exams
|
||||
|
||||
|
||||
func _sample_graphical_debrief() -> Dictionary:
|
||||
return {
|
||||
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
|
||||
"experienceMode": "graphical_interview", "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
|
||||
"interviewDebrief": {
|
||||
"likelyDiagnosis": "Probable duodenal ulcer", "summary": "A focused history supports the assessment.",
|
||||
"teachingPoints": ["Characterize dizziness.", "Screen for bleeding."]
|
||||
},
|
||||
"score": {"percent": 85.0}
|
||||
}
|
||||
|
||||
|
||||
func _sample_branching_attempt() -> Dictionary:
|
||||
var option_texts := [
|
||||
"Tell me more about the dizziness. Does the room spin, and have you fainted?",
|
||||
"How has the fatigue affected you, and how have you been sleeping and eating?",
|
||||
"You mentioned your stomach. Please describe the discomfort and when it occurs.",
|
||||
"Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?",
|
||||
"What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?",
|
||||
"Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?",
|
||||
"Do you smoke, drink alcohol, or use recreational drugs?",
|
||||
"Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?"
|
||||
]
|
||||
var choices: Array = []
|
||||
for index in 8:
|
||||
choices.append({"id": "topic_%d" % index, "text": option_texts[index], "nextNodeId": "topic_%d" % index})
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 2,
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
|
||||
"openingCompleted": false,
|
||||
"interview": {
|
||||
"currentNodeId": "chief_concern",
|
||||
"currentNode": {
|
||||
"id": "chief_concern",
|
||||
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
|
||||
"isTerminal": false,
|
||||
"choices": choices
|
||||
},
|
||||
"visitedChoiceIds": ["chief_concern"], "completed": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _visible_choice_ids(scene: Control) -> Array:
|
||||
var ids: Array = []
|
||||
for child in scene.choice_container.get_children():
|
||||
ids.append(str(child.get_meta("choice_id", "")))
|
||||
return ids
|
||||
|
||||
|
||||
func _vital_values(scene: Control) -> Array:
|
||||
var values: Array = []
|
||||
for index in range(1, scene.vitals_rows.get_child_count(), 2):
|
||||
values.append(scene.vitals_rows.get_child(index).text)
|
||||
return values
|
||||
|
||||
|
||||
func _vital_values_fit(scene: Control) -> bool:
|
||||
for index in range(1, scene.vitals_rows.get_child_count(), 2):
|
||||
var label := scene.vitals_rows.get_child(index) as Label
|
||||
if label == null or label.size.x < 220.0 or label.get_line_count() != 1:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _choice_labels_fit(scene: Control) -> bool:
|
||||
for button in scene.choice_container.get_children():
|
||||
var label := button.get_child(0) as Label
|
||||
if label == null or not _label_content_fits(label):
|
||||
return false
|
||||
if not scene.choice_area.get_global_rect().encloses(button.get_global_rect()):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _label_content_fits(label: Control) -> bool:
|
||||
if label is RichTextLabel:
|
||||
return label.get_content_height() <= label.size.y + 0.5
|
||||
var plain_label := label as Label
|
||||
if plain_label == null:
|
||||
return false
|
||||
var font := plain_label.get_theme_font("font")
|
||||
var font_size := plain_label.get_theme_font_size("font_size")
|
||||
var content_height := float(plain_label.get_line_count()) * font.get_height(font_size)
|
||||
return content_height <= plain_label.size.y + 0.5
|
||||
|
||||
|
||||
func _bubble_matches_content(scene: Control) -> bool:
|
||||
var required_height: float = scene.dialogue_text.get_content_height() + scene.DIALOGUE_BUBBLE_VERTICAL_CHROME
|
||||
var expected_height := clampf(
|
||||
required_height, scene.DIALOGUE_BUBBLE_MIN_HEIGHT, scene.DIALOGUE_BUBBLE_MAX_HEIGHT)
|
||||
return is_equal_approx(scene.dialogue_bubble.size.y, expected_height) \
|
||||
and _label_content_fits(scene.dialogue_text)
|
||||
|
||||
|
||||
func _left_click(position: Vector2) -> InputEventMouseButton:
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = true
|
||||
event.position = position
|
||||
return event
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
|
||||
@@ -66,7 +66,15 @@ func _run() -> void:
|
||||
_assert(player.time_dialog.visible, "CCS dialog lifecycle test should begin with an open popup")
|
||||
player.on_hide()
|
||||
_assert(not player.time_dialog.visible, "hiding the CCS screen should dismiss popup windows")
|
||||
player.visible = false
|
||||
ccs_service.attempt.openingCompleted = false
|
||||
player._render()
|
||||
await process_frame
|
||||
_assert(not player.intro_dialog.visible, "a hidden classic player must not open dialogs over CCS Cases 2")
|
||||
_assert(not player.opening_active, "a hidden classic player must not begin its opening sequence")
|
||||
|
||||
player.visible = true
|
||||
ccs_service.attempt.openingCompleted = true
|
||||
ccs_service.attempt.state = "final_orders"
|
||||
player._render()
|
||||
_assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
|
||||
|
||||
@@ -183,8 +183,10 @@ test("public API and disposable learner journey", async () => {
|
||||
const { data: cases } = await call("/api/ccs/cases", { token });
|
||||
expect(Array.isArray(cases)).toBeTruthy();
|
||||
expect(cases.length).toBeGreaterThan(0);
|
||||
const managementCase = cases.find((item) => item.experienceMode === "management");
|
||||
expect(managementCase, "UAT requires a published management CCS case for the smoke journey").toBeTruthy();
|
||||
const { data: attempt } = await call("/api/ccs/attempts", {
|
||||
method: "POST", token, body: { caseExternalId: "", mode: "timed" }
|
||||
method: "POST", token, body: { caseExternalId: managementCase.externalId, mode: "timed" }
|
||||
});
|
||||
activeCcsAttempt = attempt.id;
|
||||
expect(activeCcsAttempt).toBeTruthy();
|
||||
@@ -204,6 +206,8 @@ test("public API and disposable learner journey", async () => {
|
||||
}
|
||||
});
|
||||
expect(opened.openingCompleted).toBe(true);
|
||||
expect(Array.isArray(opened.availableExams)).toBeTruthy();
|
||||
expect(opened.availableExams.length, "management CCS smoke case must expose at least one exam").toBeGreaterThan(0);
|
||||
const exam = opened.availableExams[0];
|
||||
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
|
||||
method: "POST", token, body: {
|
||||
|
||||
Reference in New Issue
Block a user