Author SHA1 Message Date
hz 030bcf6d7a Fix graphical final orders workflow
Deploy STEP3 UAT / deploy (push) Canceled after 0s
2026-08-14 19:31:31 -05:00
hz 25c2188264 Schedule graphical CCS procedures 2026-08-14 17:54:03 -05:00
hz 375b3ea62f Clarify and filter graphical orders 2026-08-14 17:02:35 -05:00
hz 325d27eec5 Add graphical CCS orders and finalization 2026-08-14 00:53:21 -05:00
hz 7da07cb2a4 Refine physical exam chart layout 2026-08-13 22:21:02 -05:00
hz 6312b45a92 Preserve dialogue after examinations 2026-08-13 20:54:50 -05:00
hz 07195dd24a Prioritize examination modal input 2026-08-12 01:48:15 -05:00
hz 026f0be174 Restore terminal examination review 2026-08-11 22:28:21 -05:00
hz 9a54bfc6d6 Integrate examinations into CCS interview 2026-08-11 22:27:37 -05:00
hz 6108011f74 Refresh stale CCS exam attempts 2026-08-10 21:30:43 -05:00
hz 07516784d2 Add graphical CCS physical examination 2026-08-10 03:27:45 -05:00
hz 579e1e9be4 Refine CCS desktop shortcuts 2026-08-10 01:05:06 -05:00
hz 71f33453d3 Polish CCS computer desktop 2026-08-10 00:45:01 -05:00
hz 33b55753cb Expand CCS patient history 2026-08-09 23:59:28 -05:00
hz d349998d12 Make CCS vitals window draggable 2026-08-09 22:20:20 -05:00
hz 63b9d3c557 Fix CCS vitals layout 2026-08-09 21:35:41 -05:00
hz 065db96211 Add CCS computer vitals app 2026-08-07 22:44:30 -05:00
hz 2b25e25424 Remove CCS viewport letterboxing 2026-08-07 19:17:20 -05:00
hz 91690b2fe9 Scale CCS experience to 16:9 2026-08-07 19:04:16 -05:00
hz e50a97ba91 Add simulated CCS computer 2026-08-07 18:31:00 -05:00
hz 41b3092552 Polish fullscreen CCS dialogue 2026-08-07 17:56:49 -05:00
hz bab73b53ff Pad CCS continuation prompt 2026-08-07 02:33:25 -05:00
hz a4115f4971 Fix CCS scene-wide dialogue clicks 2026-08-06 23:41:42 -05:00
hz 6f81e5af62 Improve CCS dialogue advancement 2026-08-06 22:51:25 -05:00
hz 6e039f00a7 Size CCS speech bubbles to content 2026-08-06 20:39:40 -05:00
hz dfc224660e Constrain CCS patient dialogue 2026-08-06 05:13:21 -05:00
hz 2c7c5dd387 Refine graphical CCS interview flow 2026-08-06 04:58:15 -05:00
hz ee8e8cf787 Improve CCS dialogue layout 2026-08-06 04:41:44 -05:00
hz 4792ff2620 Prevent hidden CCS opening overlays 2026-08-06 04:23:53 -05:00
hz 38d62918d4 Resume stale graphical CCS interviews 2026-08-06 03:34:07 -05:00
hz fa981f68ee Run UAT deploys for smoke test changes 2026-08-06 02:57:44 -05:00
hz 0c330380da Keep UAT CCS smoke on management cases 2026-08-06 02:57:08 -05:00
hz a0dae8e478 Make graphical CCS dialogue choice first 2026-08-06 02:48:44 -05:00
hz 58dd370149 Add graphical duodenal ulcer interview 2026-08-05 23:14:51 -05:00
hz 7709d16018 Show legacy CCS cases in interactive scene 2026-08-05 20:46:55 -05:00
28 changed files with 3530 additions and 113 deletions
+4
View File
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2 echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1 exit 1
fi 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 fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
+1 -1
View File
@@ -7,7 +7,7 @@ on:
paths-ignore: paths-ignore:
- '**.md' - '**.md'
- 'tools/**' - 'tools/**'
- 'tests/**' - 'tests/*.gd'
workflow_dispatch: {} workflow_dispatch: {}
concurrency: concurrency:
+167
View File
@@ -91,6 +91,52 @@ public sealed class CcsCaseTests
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12); 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] [Fact]
public void PublishedCaseRequiresReferencesAndKnownOrders() public void PublishedCaseRequiresReferencesAndKnownOrders()
{ {
@@ -177,6 +223,108 @@ public sealed class CcsCaseTests
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow)); 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] [Fact]
public void CcsExpectedScoreUsesSeparateUserAndCaseElo() public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
{ {
@@ -253,4 +401,23 @@ public sealed class CcsCaseTests
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true } 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"]
}
]
};
} }
+2
View File
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/ COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/ COPY content/dynamic_questions/ content/dynamic_questions/
COPY content/ccs/ content/ccs/
WORKDIR /src/api/QuestionsApi WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION} 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 WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
COPY --from=build /src/content/ccs ./content/ccs
ENV ASPNETCORE_URLS=http://+:5008 \ ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \ ASPNETCORE_ENVIRONMENT=Production \
+62
View File
@@ -64,6 +64,8 @@ public class CcsCaseRevision
public int RealTimeMinutes { get; set; } = 20; public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2; public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = []; 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 CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new(); public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
@@ -79,12 +81,47 @@ public class CcsInitialInterview
{ {
public string StartNodeId { get; set; } = ""; public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { 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 class CcsDialogueNode
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
public string PatientText { get; set; } = ""; public string PatientText { get; set; } = "";
public bool SuppressPatientText { get; set; }
public bool IsTerminal { get; set; } public bool IsTerminal { get; set; }
public List<CcsDialogueChoice> Choices { get; set; } = []; public List<CcsDialogueChoice> Choices { get; set; } = [];
} }
@@ -127,6 +164,8 @@ public class CcsCaseOrderDefinition
{ {
public string OrderCode { get; set; } = ""; public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; } public int? TurnaroundMinutes { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
public string Result { get; set; } = ""; public string Result { get; set; } = "";
public string ResultSection { get; set; } = ""; public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = []; public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -206,6 +245,8 @@ public class CcsPendingResult
public string Category { get; set; } = ""; public string Category { get; set; } = "";
public string Title { get; set; } = ""; public string Title { get; set; } = "";
public int DueMinute { get; set; } public int DueMinute { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
public string Result { get; set; } = ""; public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = []; public Dictionary<string, string> VitalPatches { get; set; } = [];
} }
@@ -234,10 +275,22 @@ public class CcsActiveOrder
public string Frequency { get; set; } = ""; public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; } public int OrderedMinute { get; set; }
public int? ReportMinute { 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 IsPending { get; set; }
public bool HasResult { 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 class CcsCaseScore
{ {
public double Percent { get; set; } public double Percent { get; set; }
@@ -270,6 +323,7 @@ public class CcsImportResponse
{ {
public int OrdersUpserted { get; set; } public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; } public int CasesCreated { get; set; }
public int CasesSkipped { get; set; }
public List<string> Errors { get; set; } = []; public List<string> Errors { get; set; } = [];
} }
@@ -312,6 +366,9 @@ public class CcsCaseSummary
public int Difficulty { get; set; } public int Difficulty { get; set; }
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { 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 public class CcsAttemptResponse
@@ -332,20 +389,25 @@ public class CcsAttemptResponse
public DateTime CreatedUtc { get; set; } public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; } public int RealTimeMinutes { get; set; }
public int ElapsedSeconds { 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 PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = ""; public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = ""; public string Appearance { get; set; } = "";
public string History { get; set; } = ""; public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = []; public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public string MedicalHistory { get; set; } = "";
public Dictionary<string, string> Vitals { get; set; } = []; public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = []; public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = []; public List<string> ActiveOrderCodes { get; set; } = [];
public List<CcsActiveOrder> ActiveOrders { get; set; } = []; public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
public List<CcsOrderOption> OrderOptions { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = []; public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = []; public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = []; public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; } public CcsInterviewStateResponse? Interview { get; set; }
public CcsInterviewDebrief? InterviewDebrief { get; set; }
public CcsCaseScore? Score { get; set; } public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; } public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = []; public List<QuestionReference> References { get; set; } = [];
+6
View File
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return; 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") if (args.FirstOrDefault() == "export-dynamic-review")
{ {
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); 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 (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.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.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)) if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required"); errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase)) if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
@@ -54,6 +60,9 @@ public static class CcsCaseValidator
foreach (var response in item.OrderResponses) foreach (var response in item.OrderResponses)
{ {
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}"); 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)) if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid"); 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 == "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 == "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 == "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) 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) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required"); if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; } 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;
}
}
+237 -20
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true }); await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
} }
var created = 0; var created = 0;
var skipped = 0;
foreach (var incoming in request.Cases) foreach (var incoming in request.Cases)
{ {
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent) var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync(); .SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null) if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false)); await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null; incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming); await _cases.InsertOneAsync(incoming);
created++; 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() => 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."); if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt var attempt = new CcsAttempt
{ {
UserId = userId, UserId = userId,
@@ -117,7 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version, CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed, Mode = CcsAttemptModes.Timed,
OpeningCompleted = false, OpeningCompleted = false,
InterviewNodeId = definition.InitialInterview.StartNodeId, InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation, Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals), CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory = VitalHistory =
@@ -125,9 +143,11 @@ public sealed class CcsStore
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) } new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
], ],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)), DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes), ? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
RatingEligible = true, HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart = Chart =
[ [
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter }, 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 definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed; 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 List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? [] ? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync(); : await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
@@ -403,11 +440,19 @@ public sealed class CcsStore
var reported = attempt.Chart.LastOrDefault(value => var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase) value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders); && 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 return new CcsActiveOrder
{ {
Code = order.Code, Name = order.CanonicalName, Category = order.Category, Code = order.Code, Name = order.CanonicalName, Category = order.Category,
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute, Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
ReportMinute = pending is not null ? null : reported?.SimulatedMinute, 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 IsPending = pending is not null, HasResult = reported is not null
}; };
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList(); }).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
@@ -415,8 +460,10 @@ public sealed class CcsStore
? attempt.VitalHistory ? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }]; : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null; CcsInterviewStateResponse? interview = null;
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value => var currentInterviewNode = CurrentInterviewNode(attempt, definition);
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase)); var openingCompleted = attempt.OpeningCompleted ?? true;
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
openingCompleted = false;
if (currentInterviewNode is not null) if (currentInterviewNode is not null)
{ {
interview = new CcsInterviewStateResponse interview = new CcsInterviewStateResponse
@@ -424,7 +471,7 @@ public sealed class CcsStore
CurrentNodeId = currentInterviewNode.Id, CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode, CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds, VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = attempt.OpeningCompleted ?? false Completed = openingCompleted
}; };
} }
return new CcsAttemptResponse return new CcsAttemptResponse
@@ -433,23 +480,31 @@ public sealed class CcsStore
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}", CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc, 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, DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), 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, PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, Appearance = definition.InitialChart.Appearance,
HistorySections = definition.InitialChart.HistorySections, 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, 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 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(), }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview, Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : [] References = completed ? definition.References : []
}; };
@@ -457,13 +512,25 @@ public sealed class CcsStore
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now) 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."); 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)) value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The interactive interview is unavailable."); ?? throw new InvalidOperationException("The interactive interview is unavailable.");
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase)) 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."); ?? 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)) value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The dialogue choice target is unavailable."); ?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
@@ -484,23 +551,31 @@ public sealed class CcsStore
{ {
code = code.Trim().ToLowerInvariant(); code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code."); 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."); throw new InvalidOperationException("That order is unavailable at the current location.");
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code); 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 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); var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0)) if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
attempt.PendingResults.Add(new CcsPendingResult attempt.PendingResults.Add(new CcsPendingResult
{ {
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName, 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 attempt.Chart.Add(new CcsChartEntry
{ {
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName, 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); if (delay == 0 && processImmediately) ProcessDue(attempt);
} }
@@ -565,6 +640,13 @@ public sealed class CcsStore
var vitalsChanged = false; var vitalsChanged = false;
foreach (var result in due) 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)) if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry attempt.Chart.Add(new CcsChartEntry
{ {
@@ -578,6 +660,13 @@ public sealed class CcsStore
if (vitalsChanged) SnapshotVitals(attempt); 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) private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
{ {
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); 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, ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes, PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags, 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) internal static int Rank(CcsOrderDefinition order, string query)
{ {
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant()); 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
+4
View File
@@ -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

+5
View File
@@ -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

+43
View File
@@ -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
+201
View File
@@ -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
View File
@@ -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. 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 ## 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. - `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.
+391 -21
View File
@@ -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="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"] [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="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="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/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"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
shader = ExtResource("5_patient_fit") shader = ExtResource("5_patient_fit")
@@ -103,15 +109,17 @@ antialiased = true
[node name="DialogueBubble" type="Control" parent="."] [node name="DialogueBubble" type="Control" parent="."]
z_index = 30 z_index = 30
clip_contents = true
layout_mode = 1 layout_mode = 1
anchors_preset = 0 anchors_preset = 0
anchor_left = 0.51 anchor_left = 0.53
anchor_top = 0.055 anchor_top = 0.04
anchor_right = 0.51 anchor_right = 0.53
anchor_bottom = 0.055 anchor_bottom = 0.04
offset_right = 420.0 offset_right = 650.0
offset_bottom = 140.0 offset_bottom = 220.0
mouse_filter = 2 mouse_default_cursor_shape = 2
focus_mode = 2
[node name="Background" type="TextureRect" parent="DialogueBubble"] [node name="Background" type="TextureRect" parent="DialogueBubble"]
layout_mode = 1 layout_mode = 1
@@ -126,33 +134,395 @@ texture = ExtResource("6_chat_bubble")
expand_mode = 1 expand_mode = 1
stretch_mode = 0 stretch_mode = 0
[node name="Text" type="Label" parent="DialogueBubble"] [node name="Text" type="RichTextLabel" parent="DialogueBubble"]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = 76.0 offset_left = 96.0
offset_top = 31.0 offset_top = 31.0
offset_right = -48.0 offset_right = -64.0
offset_bottom = -43.0 offset_bottom = -64.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1) 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." text = "Hello, doctor."
fit_content = false
scroll_active = false
autowrap_mode = 2 autowrap_mode = 2
visible_characters_behavior = 1
vertical_alignment = 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 z_index = 29
layout_mode = 1 layout_mode = 1
anchors_preset = 0 anchors_preset = 12
anchor_left = 0.5
anchor_top = 1.0 anchor_top = 1.0
anchor_right = 0.5 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
offset_left = -210.0 offset_left = 18.0
offset_top = -206.0 offset_top = -194.0
offset_right = 210.0 offset_right = -18.0
offset_bottom = -4.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
+228 -21
View File
@@ -3,7 +3,9 @@ extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd")
const Style := preload("res://scripts/ui/ui_style.gd") const Style := preload("res://scripts/ui/ui_style.gd")
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn") 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 main
var stage_root: Control var stage_root: Control
@@ -16,6 +18,8 @@ var current_view := ""
var cases_loaded := false var cases_loaded := false
var active_loaded := false var active_loaded := false
var pending_action := "" var pending_action := ""
var pending_exam_code := ""
var restart_case_external_id := ""
func _ready() -> void: func _ready() -> void:
@@ -39,10 +43,15 @@ func _connect_service() -> void:
CCSService.attempt_changed.connect(_on_attempt_changed) CCSService.attempt_changed.connect(_on_attempt_changed)
if not CCSService.request_failed.is_connected(_on_request_failed): if not CCSService.request_failed.is_connected(_on_request_failed):
CCSService.request_failed.connect(_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: func on_show(_payload: Variant = null) -> void:
CCSService.launch_origin = "ccs_cases_2" CCSService.launch_origin = "ccs_cases_2"
restart_case_external_id = ""
cases_loaded = false cases_loaded = false
active_loaded = false active_loaded = false
pending_action = "loading" pending_action = "loading"
@@ -52,12 +61,27 @@ func on_show(_payload: Variant = null) -> void:
func on_hide() -> void: func on_hide() -> void:
restart_case_external_id = ""
_clear_active_scene() _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: func compatible_cases(cases: Array) -> Array:
return cases.filter(func(item: Variant) -> bool: 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: func _on_cases_changed() -> void:
@@ -67,16 +91,49 @@ func _on_cases_changed() -> void:
func _on_attempt_changed() -> void: func _on_attempt_changed() -> void:
active_loaded = true active_loaded = true
var completed_action := pending_action
var completed_exam_code := pending_exam_code
pending_action = "" 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() _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: func _render_state() -> void:
if not visible: if not visible:
return return
if not active_loaded: if not active_loaded:
return return
var attempt: Dictionary = CCSService.attempt 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"]: if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
_render_attempt(attempt) _render_attempt(attempt)
elif cases_loaded and active_loaded: elif cases_loaded and active_loaded:
@@ -84,21 +141,25 @@ func _render_state() -> void:
func _render_attempt(attempt: Dictionary) -> void: func _render_attempt(attempt: Dictionary) -> void:
if str(attempt.get("state", "")) != "active": if str(attempt.get("state", "")) not in ["active", "final_orders"]:
_handoff_to_classic_player() _show_unsupported_attempt("This active case is not available in the graphical interview yet.")
return return
var interview: Dictionary = attempt.get("interview", {}) var interview: Dictionary = attempt.get("interview", {})
if interview.is_empty(): if interview.is_empty():
_handoff_to_classic_player() _show_unsupported_attempt("This active case does not include a graphical dialogue interview.")
return return
var node: Dictionary = interview.get("currentNode", {}) var node: Dictionary = interview.get("currentNode", {})
if node.is_empty(): if node.is_empty():
_handoff_to_classic_player() _show_unsupported_attempt("The graphical interview could not load its current dialogue.")
return return
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)): if _is_retired_interview_revision(attempt, node):
_handoff_to_classic_player() _restart_with_current_interview(attempt)
return 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: func _build_selection_view() -> void:
@@ -113,7 +174,7 @@ func _build_selection_view() -> void:
heading.add_theme_font_size_override("font_size", 28) heading.add_theme_font_size_override("font_size", 28)
selection_view.add_child(heading) selection_view.add_child(heading)
var description := Label.new() 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 description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Brand.apply_font(description) Brand.apply_font(description)
description.add_theme_font_size_override("font_size", 16) description.add_theme_font_size_override("font_size", 16)
@@ -151,12 +212,18 @@ func _show_selection(message: String = "") -> void:
retry_button.visible = false 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: func _render_case_selection() -> void:
_show_selection() _show_selection()
_clear_children(case_list) _clear_children(case_list)
var available := compatible_cases(CCSService.cases) var available := compatible_cases(CCSService.cases)
if available.is_empty(): 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 return
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"] status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
retry_button.visible = false retry_button.visible = false
@@ -183,14 +250,31 @@ func _start_case(case_external_id: String) -> void:
CCSService.create_attempt(case_external_id, "timed") 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: if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office") _set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
active_scene.choice_selected.connect(_submit_dialogue_choice) 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 selection_view.visible = false
stage_root.visible = true 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_interview(node)
active_scene.configure_orders(attempt)
active_scene.set_choices_pending(not pending_action.is_empty()) 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) CCSService.send_dialogue_choice(choice_id)
func _handoff_to_classic_player() -> void: func _submit_physical_exam(exam_code: String) -> void:
current_view = "handoff" if not pending_action.is_empty() or exam_code.is_empty():
if main != null: return
main.show_screen("ccs_player") 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: 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..." status_label.text = "An active case was found. Loading it now..."
CCSService.load_active() CCSService.load_active()
return 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: 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: else:
status_label.text = message status_label.text = message
retry_button.visible = failed_action == "loading" retry_button.visible = failed_action == "loading"
@@ -264,6 +471,6 @@ func _fit_stage() -> void:
var available := size var available := size
if available.x <= 0.0 or available.y <= 0.0: if available.x <= 0.0 or available.y <= 0.0:
return return
var fit_scale := minf(available.x / DESIGN_SIZE.x, available.y / DESIGN_SIZE.y) stage_root.scale = Vector2.ONE
stage_root.scale = Vector2.ONE * fit_scale stage_root.position = Vector2.ZERO
stage_root.position = (available - DESIGN_SIZE * fit_scale) * 0.5 stage_root.size = available
+7
View File
@@ -435,6 +435,7 @@ func on_show(payload: Variant = null) -> void:
func on_hide() -> void: func on_hide() -> void:
timer.stop() timer.stop()
opening_active = false
_dismiss_dialogs() _dismiss_dialogs()
@@ -458,6 +459,12 @@ func _ccs_dialogs() -> Array[Window]:
func _render() -> void: 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 var attempt: Dictionary = CCSService.attempt
if attempt.is_empty(): if attempt.is_empty():
return return
+32 -6
View File
@@ -126,11 +126,11 @@ func _apply_screen_margins(screen_name: String) -> void:
if screens_margin == null or screen_name.is_empty(): if screens_margin == null or screen_name.is_empty():
return return
var is_fullscreen_study := screen_name == "player" 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_left", margin)
screens_margin.add_theme_constant_override("margin_right", 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_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 == "login" else (12 if is_fullscreen_study else 28)) 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: func _build_ui() -> void:
@@ -582,7 +582,7 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
func _screen_uses_shell(screen_name: String) -> bool: 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: 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": if not AuthState.is_logged_in or current_screen_name == "login":
return return
if event.is_action_pressed("ui_cancel"): if event.is_action_pressed("ui_cancel"):
if _close_current_transient_overlay():
get_viewport().set_input_as_handled()
return
if drawer_open: if drawer_open:
_close_drawer() _close_drawer()
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return return
if current_screen_name in ["player", "ccs_player", "flashcards_study"]: if _screen_uses_pause_menu(current_screen_name):
_toggle_app_menu() _toggle_app_menu()
get_viewport().set_input_as_handled() 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: func _refresh_header_controls() -> void:
if feedback_button != null: if feedback_button != null:
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0 feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
@@ -875,13 +889,15 @@ func _toggle_app_menu() -> void:
_close_app_menu() _close_app_menu()
else: else:
UISoundService.play_sound("open") UISoundService.play_sound("open")
_set_current_screen_pause_menu_open(true)
app_menu.visible = true app_menu.visible = true
app_menu.size = app_menu.custom_minimum_size app_menu.size = app_menu.custom_minimum_size
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 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: if menu_exit_button.visible:
menu_exit_button.text = ( menu_exit_button.text = (
"Abandon CCS Case" if current_screen_name == "ccs_player" "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 "Exit Study Session" if current_screen_name == "flashcards_study"
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
else "Exit Run") else "Exit Run")
@@ -892,9 +908,16 @@ func _toggle_app_menu() -> void:
func _close_app_menu() -> void: func _close_app_menu() -> void:
if app_menu != null: if app_menu != null:
app_menu.visible = false app_menu.visible = false
_set_current_screen_pause_menu_open(false)
BlockSessionService.resume_current_question_timer() 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: func _navigate_from_pause(target_screen: String) -> void:
var return_screen := current_screen_name var return_screen := current_screen_name
show_screen(target_screen, {"return_screen": return_screen}) show_screen(target_screen, {"return_screen": return_screen})
@@ -911,6 +934,9 @@ func exit_current_run() -> void:
if current_screen_name == "flashcards_study": if current_screen_name == "flashcards_study":
show_screen("flashcards_hub") show_screen("flashcards_hub")
return return
if current_screen_name == "ccs_cases_2":
show_screen("dashboard")
return
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
BlockSessionService.finish_block() BlockSessionService.finish_block()
elif BlockSessionService.session_kind == "simulation": elif BlockSessionService.session_kind == "simulation":
+404 -5
View File
@@ -23,6 +23,16 @@ func _run() -> void:
var nav_button := main_screen.sidebar.find_child("NavCcsCases2", true, false) as Button 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(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(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() main_screen.queue_free()
await process_frame await process_frame
ProjectSettings.set_setting("step3/deployment_environment", prior_environment) ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
@@ -40,7 +50,11 @@ func _run() -> void:
await process_frame await process_frame
service.cases = [ 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} {"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
] ]
service.attempt = {} service.attempt = {}
@@ -50,22 +64,278 @@ func _run() -> void:
await process_frame await process_frame
_assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection") _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") _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) service.attempt = _sample_attempt(false)
screen._render_state() screen._render_state()
await process_frame await process_frame
_assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office") _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(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) service.attempt = _sample_attempt(true)
screen._on_attempt_changed() screen._on_attempt_changed()
await process_frame await process_frame
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response") _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") _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(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
_assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player") 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() screen.on_hide()
await process_frame await process_frame
@@ -83,15 +353,144 @@ func _sample_attempt(completed: bool) -> Dictionary:
"id": "done" if completed else "greeting", "id": "done" if completed else "greeting",
"patientText": "Okay, doctor." if completed else "Hello, doctor.", "patientText": "Okay, doctor." if completed else "Hello, doctor.",
"isTerminal": completed, "isTerminal": completed,
"suppressPatientText": not completed,
"choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}] "choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}]
} }
return { return {
"id": "attempt-1", "state": "active", "revision": 1, "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, "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} "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: func _assert(condition: bool, message: String) -> void:
if condition: if condition:
return return
+8
View File
@@ -66,7 +66,15 @@ func _run() -> void:
_assert(player.time_dialog.visible, "CCS dialog lifecycle test should begin with an open popup") _assert(player.time_dialog.visible, "CCS dialog lifecycle test should begin with an open popup")
player.on_hide() player.on_hide()
_assert(not player.time_dialog.visible, "hiding the CCS screen should dismiss popup windows") _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" ccs_service.attempt.state = "final_orders"
player._render() player._render()
_assert(player.final_orders_notice.visible, "final-orders mode should show instructions") _assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
+5 -1
View File
@@ -183,8 +183,10 @@ test("public API and disposable learner journey", async () => {
const { data: cases } = await call("/api/ccs/cases", { token }); const { data: cases } = await call("/api/ccs/cases", { token });
expect(Array.isArray(cases)).toBeTruthy(); expect(Array.isArray(cases)).toBeTruthy();
expect(cases.length).toBeGreaterThan(0); 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", { 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; activeCcsAttempt = attempt.id;
expect(activeCcsAttempt).toBeTruthy(); expect(activeCcsAttempt).toBeTruthy();
@@ -204,6 +206,8 @@ test("public API and disposable learner journey", async () => {
} }
}); });
expect(opened.openingCompleted).toBe(true); 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 exam = opened.availableExams[0];
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, { const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
method: "POST", token, body: { method: "POST", token, body: {