Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07516784d2 | ||
|
|
579e1e9be4 | ||
|
|
71f33453d3 | ||
|
|
33b55753cb | ||
|
|
d349998d12 | ||
|
|
63b9d3c557 | ||
|
|
065db96211 | ||
|
|
2b25e25424 | ||
|
|
91690b2fe9 | ||
|
|
e50a97ba91 | ||
|
|
41b3092552 | ||
|
|
bab73b53ff | ||
|
|
a4115f4971 | ||
|
|
6f81e5af62 | ||
|
|
6e039f00a7 | ||
|
|
dfc224660e | ||
|
|
2c7c5dd387 | ||
|
|
ee8e8cf787 | ||
|
|
4792ff2620 | ||
|
|
38d62918d4 | ||
|
|
fa981f68ee | ||
|
|
0c330380da | ||
|
|
a0dae8e478 | ||
|
|
58dd370149 | ||
|
|
7709d16018 |
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
|
||||
echo "Dynamic question promotion failed after 3 attempts." >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
|
||||
dotnet QuestionsApi.dll promote-ccs-cases \
|
||||
--source /app/content/ccs/duodenal-ulcer-office-001.json \
|
||||
--apply
|
||||
fi
|
||||
|
||||
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
- 'tests/*.gd'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -91,6 +91,41 @@ public sealed class CcsCaseTests
|
||||
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
|
||||
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
Assert.NotNull(request);
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
||||
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
||||
var topics = request.Cases.Single().InitialInterview.Topics;
|
||||
Assert.Equal([
|
||||
"chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
|
||||
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
|
||||
], topics.Select(value => value.Id));
|
||||
Assert.Equal(6, topics.Count(value => value.Required));
|
||||
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
|
||||
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
|
||||
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
|
||||
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
|
||||
Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(2, 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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedCaseRequiresReferencesAndKnownOrders()
|
||||
{
|
||||
@@ -177,6 +212,108 @@ public sealed class CcsCaseTests
|
||||
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyCaseGetsInterviewFromExistingChartNarrative()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = new();
|
||||
definition.InitialChart.HistorySections =
|
||||
[
|
||||
new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." },
|
||||
new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." }
|
||||
];
|
||||
|
||||
var interview = CcsStore.EffectiveInterview(definition);
|
||||
var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false };
|
||||
|
||||
Assert.Equal("opening", interview.StartNodeId);
|
||||
Assert.Equal(5, interview.Nodes.Count);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow);
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
||||
Assert.Equal("complete", attempt.InterviewNodeId);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoredInterviewRemainsPreferredOverGeneratedFallback()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = ValidInterview();
|
||||
|
||||
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = TopicInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
|
||||
|
||||
var opening = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.True(opening.SuppressPatientText);
|
||||
Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
||||
var chiefConcern = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.False(chiefConcern.SuppressPatientText);
|
||||
Assert.Equal("I feel tired.", chiefConcern.PatientText);
|
||||
Assert.Equal(["required_history", "optional_history"], chiefConcern.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
|
||||
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
|
||||
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewRepairsLegacyOpeningCompletionFlag()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = TopicInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true };
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
||||
|
||||
Assert.False(attempt.OpeningCompleted);
|
||||
Assert.Equal("chief_concern", attempt.InterviewNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewRejectsPrerequisiteCycles()
|
||||
{
|
||||
var request = ValidImport();
|
||||
request.Cases[0].InitialInterview = TopicInterview();
|
||||
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
|
||||
|
||||
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
|
||||
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseContentComparisonIgnoresRevisionMetadata()
|
||||
{
|
||||
var first = ValidImport().Cases[0];
|
||||
var second = ValidImport().Cases[0];
|
||||
first.Id = "507f1f77bcf86cd799439011";
|
||||
first.Version = 4;
|
||||
first.IsCurrent = false;
|
||||
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
|
||||
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
|
||||
|
||||
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
second.Title = "Changed title";
|
||||
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
||||
{
|
||||
@@ -253,4 +390,23 @@ public sealed class CcsCaseTests
|
||||
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
|
||||
]
|
||||
};
|
||||
|
||||
private static CcsInitialInterview TopicInterview() => new()
|
||||
{
|
||||
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
|
||||
Topics =
|
||||
[
|
||||
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
|
||||
new()
|
||||
{
|
||||
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
|
||||
|
||||
COPY api/QuestionsApi/ api/QuestionsApi/
|
||||
COPY content/dynamic_questions/ content/dynamic_questions/
|
||||
COPY content/ccs/ content/ccs/
|
||||
WORKDIR /src/api/QuestionsApi
|
||||
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
|
||||
|
||||
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
|
||||
COPY --from=build /src/content/ccs ./content/ccs
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:5008 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
|
||||
@@ -64,6 +64,8 @@ public class CcsCaseRevision
|
||||
public int RealTimeMinutes { get; set; } = 20;
|
||||
public int Difficulty { get; set; } = 2;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public CcsInitialChart InitialChart { get; set; } = new();
|
||||
public CcsInitialInterview InitialInterview { get; set; } = new();
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
@@ -79,12 +81,47 @@ public class CcsInitialInterview
|
||||
{
|
||||
public string StartNodeId { get; set; } = "";
|
||||
public List<CcsDialogueNode> Nodes { get; set; } = [];
|
||||
public string OpeningPatientText { get; set; } = "";
|
||||
public List<CcsInterviewTopic> Topics { get; set; } = [];
|
||||
public string FinishChoiceText { get; set; } = "Finish the interview";
|
||||
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
|
||||
public CcsInterviewDebrief Debrief { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsInterviewTopic
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string ClinicianText { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool Required { get; set; }
|
||||
public List<string> PrerequisiteTopicIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsInterviewDebrief
|
||||
{
|
||||
public string LikelyDiagnosis { get; set; } = "";
|
||||
public string Summary { get; set; } = "";
|
||||
public List<string> TeachingPoints { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsGraphicalPresentation
|
||||
{
|
||||
public string SceneKey { get; set; } = "";
|
||||
public string PatientAssetKey { get; set; } = "";
|
||||
public int InterviewRevision { get; set; }
|
||||
}
|
||||
|
||||
public static class CcsExperienceModes
|
||||
{
|
||||
public const string Management = "management";
|
||||
public const string GraphicalInterview = "graphical_interview";
|
||||
}
|
||||
|
||||
public class CcsDialogueNode
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool SuppressPatientText { get; set; }
|
||||
public bool IsTerminal { get; set; }
|
||||
public List<CcsDialogueChoice> Choices { get; set; } = [];
|
||||
}
|
||||
@@ -270,6 +307,7 @@ public class CcsImportResponse
|
||||
{
|
||||
public int OrdersUpserted { get; set; }
|
||||
public int CasesCreated { get; set; }
|
||||
public int CasesSkipped { get; set; }
|
||||
public List<string> Errors { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -312,6 +350,9 @@ public class CcsCaseSummary
|
||||
public int Difficulty { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool InteractiveInterviewAvailable { get; set; }
|
||||
public bool GraphicalPresentationAvailable { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CcsAttemptResponse
|
||||
@@ -332,11 +373,14 @@ public class CcsAttemptResponse
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
|
||||
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
|
||||
public string PatientSummary { get; set; } = "";
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public string MedicalHistory { get; set; } = "";
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
@@ -346,6 +390,7 @@ public class CcsAttemptResponse
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public CcsInterviewStateResponse? Interview { get; set; }
|
||||
public CcsInterviewDebrief? InterviewDebrief { get; set; }
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
|
||||
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-ccs-cases")
|
||||
{
|
||||
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "export-dynamic-review")
|
||||
{
|
||||
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
|
||||
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
|
||||
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
|
||||
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
|
||||
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
|
||||
errors.Add(prefix + "experienceMode is invalid");
|
||||
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|
||||
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
|
||||
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
|
||||
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
|
||||
errors.Add(prefix + "initial patient summary and reason are required");
|
||||
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
||||
@@ -73,6 +79,10 @@ public static class CcsCaseValidator
|
||||
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
|
||||
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
|
||||
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
|
||||
if (criterion.ActionType == "dialogue_choice"
|
||||
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
|
||||
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
|
||||
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +133,55 @@ public static class CcsCaseValidator
|
||||
|
||||
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
|
||||
{
|
||||
if (interview.Topics.Count > 0)
|
||||
{
|
||||
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
|
||||
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
|
||||
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
|
||||
errors.Add(prefix + "topic interview requires openingPatientText");
|
||||
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
|
||||
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
|
||||
var topicIds = interview.Topics.Select(value => value.Id).ToList();
|
||||
if (topicIds.Any(string.IsNullOrWhiteSpace)
|
||||
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
|
||||
errors.Add(prefix + "interview topic ids must be present and unique");
|
||||
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + "finish_interview is a reserved interview topic id");
|
||||
if (!interview.Topics.Any(value => value.Required))
|
||||
errors.Add(prefix + "topic interview requires at least one required topic");
|
||||
foreach (var topic in interview.Topics)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
|
||||
if (string.IsNullOrWhiteSpace(topic.PatientText))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds)
|
||||
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|
||||
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
|
||||
}
|
||||
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
||||
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool ResolveTopic(string topicId)
|
||||
{
|
||||
if (resolving.Contains(topicId))
|
||||
{
|
||||
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
|
||||
return false;
|
||||
}
|
||||
if (resolved.Contains(topicId)) return true;
|
||||
resolving.Add(topicId);
|
||||
if (topicMap.TryGetValue(topicId, out var topic))
|
||||
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
|
||||
ResolveTopic(prerequisite);
|
||||
resolving.Remove(topicId);
|
||||
resolved.Add(topicId);
|
||||
return true;
|
||||
}
|
||||
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
|
||||
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
|
||||
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsPromotionCommand
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
|
||||
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
|
||||
? args[sourceIndex + 1]
|
||||
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
|
||||
var files = File.Exists(source)
|
||||
? new[] { source }
|
||||
: Directory.Exists(source)
|
||||
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
|
||||
: [];
|
||||
if (files.Length == 0)
|
||||
{
|
||||
await output.WriteLineAsync($"CCS content not found: {source}");
|
||||
return 2;
|
||||
}
|
||||
|
||||
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
|
||||
var request = new CcsImportRequest();
|
||||
foreach (var file in files)
|
||||
{
|
||||
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
|
||||
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
|
||||
request.Orders.AddRange(parsed.Orders);
|
||||
request.Cases.AddRange(parsed.Cases);
|
||||
}
|
||||
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
|
||||
if (duplicateOrders.Count > 0)
|
||||
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables().Build();
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
|
||||
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
|
||||
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) await output.WriteLineAsync(error);
|
||||
return 1;
|
||||
}
|
||||
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var order in request.Orders)
|
||||
{
|
||||
order.Code = order.Code.Trim().ToLowerInvariant();
|
||||
order.UpdatedUtc = DateTime.UtcNow;
|
||||
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
|
||||
{
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await cases.UpdateOneAsync(value => value.Id == current.Id,
|
||||
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
incoming.Version = (current?.Version ?? 0) + 1;
|
||||
incoming.IsCurrent = true;
|
||||
incoming.CreatedUtc = DateTime.UtcNow;
|
||||
await cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,16 @@ public sealed class CcsStore
|
||||
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
var created = 0;
|
||||
var skipped = 0;
|
||||
foreach (var incoming in request.Cases)
|
||||
{
|
||||
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
|
||||
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
|
||||
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (current is not null)
|
||||
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
|
||||
incoming.Id = null;
|
||||
@@ -63,7 +69,17 @@ public sealed class CcsStore
|
||||
await _cases.InsertOneAsync(incoming);
|
||||
created++;
|
||||
}
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
|
||||
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
|
||||
}
|
||||
|
||||
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
|
||||
{
|
||||
var document = value.ToBsonDocument();
|
||||
document.Remove("_id");
|
||||
document.Remove(nameof(CcsCaseRevision.Version));
|
||||
document.Remove(nameof(CcsCaseRevision.IsCurrent));
|
||||
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
|
||||
return document;
|
||||
}
|
||||
|
||||
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
|
||||
@@ -108,6 +124,8 @@ public sealed class CcsStore
|
||||
}
|
||||
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
|
||||
var now = DateTime.UtcNow;
|
||||
var interview = EffectiveInterview(definition);
|
||||
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
|
||||
var attempt = new CcsAttempt
|
||||
{
|
||||
UserId = userId,
|
||||
@@ -117,7 +135,7 @@ public sealed class CcsStore
|
||||
CaseVersion = definition.Version,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
InterviewNodeId = definition.InitialInterview.StartNodeId,
|
||||
InterviewNodeId = interviewStartNodeId,
|
||||
Location = definition.PresentingLocation,
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
VitalHistory =
|
||||
@@ -125,9 +143,11 @@ public sealed class CcsStore
|
||||
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
|
||||
],
|
||||
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = true,
|
||||
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
|
||||
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? null : now.AddMinutes(definition.RealTimeMinutes),
|
||||
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
|
||||
@@ -415,8 +435,10 @@ public sealed class CcsStore
|
||||
? attempt.VitalHistory
|
||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
||||
CcsInterviewStateResponse? interview = null;
|
||||
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
|
||||
var openingCompleted = attempt.OpeningCompleted ?? true;
|
||||
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
|
||||
openingCompleted = false;
|
||||
if (currentInterviewNode is not null)
|
||||
{
|
||||
interview = new CcsInterviewStateResponse
|
||||
@@ -424,7 +446,7 @@ public sealed class CcsStore
|
||||
CurrentNodeId = currentInterviewNode.Id,
|
||||
CurrentNode = currentInterviewNode,
|
||||
VisitedChoiceIds = attempt.InterviewChoiceIds,
|
||||
Completed = attempt.OpeningCompleted ?? false
|
||||
Completed = openingCompleted
|
||||
};
|
||||
}
|
||||
return new CcsAttemptResponse
|
||||
@@ -433,14 +455,19 @@ public sealed class CcsStore
|
||||
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
|
||||
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
||||
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
|
||||
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
|
||||
OpeningCompleted = openingCompleted, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
|
||||
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
|
||||
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
||||
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
||||
HistorySections = definition.InitialChart.HistorySections,
|
||||
Appearance = definition.InitialChart.Appearance,
|
||||
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? "" : definition.InitialChart.History,
|
||||
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? [] : definition.InitialChart.HistorySections,
|
||||
MedicalHistory = MedicalHistory(definition),
|
||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
@@ -450,6 +477,8 @@ public sealed class CcsStore
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Interview = interview,
|
||||
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
||||
? definition.InitialInterview.Debrief : null,
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
References = completed ? definition.References : []
|
||||
};
|
||||
@@ -457,13 +486,25 @@ public sealed class CcsStore
|
||||
|
||||
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
|
||||
{
|
||||
if (definition.InitialInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.OpeningCompleted == true
|
||||
&& attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The initial interview is already complete.");
|
||||
// Attempts that passed through the legacy classic opening can have
|
||||
// this flag set before any graphical topic was completed.
|
||||
attempt.OpeningCompleted = false;
|
||||
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
|
||||
return;
|
||||
}
|
||||
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
|
||||
var node = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
var interview = EffectiveInterview(definition);
|
||||
var node = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The interactive interview is unavailable.");
|
||||
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
var next = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
var next = interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
|
||||
@@ -727,8 +768,136 @@ public sealed class CcsStore
|
||||
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
||||
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
|
||||
Difficulty = value.Difficulty, Tags = value.Tags,
|
||||
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
|
||||
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
|
||||
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
|
||||
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
|
||||
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
|
||||
};
|
||||
|
||||
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
|
||||
{
|
||||
var topicInterview = definition.InitialInterview;
|
||||
if (topicInterview.Topics.Count > 0)
|
||||
{
|
||||
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
|
||||
};
|
||||
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
|
||||
? topicInterview.OpeningPatientText
|
||||
: topicInterview.Topics.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
|
||||
if (string.IsNullOrWhiteSpace(patientText)) return null;
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
|
||||
&& value.PrerequisiteTopicIds.All(visited.Contains))
|
||||
.Select(value => new CcsDialogueChoice
|
||||
{
|
||||
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
|
||||
}).ToList();
|
||||
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
choices.Add(new CcsDialogueChoice
|
||||
{
|
||||
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
|
||||
});
|
||||
return new CcsDialogueNode
|
||||
{
|
||||
Id = attempt.InterviewNodeId, PatientText = patientText,
|
||||
SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase),
|
||||
Choices = choices
|
||||
};
|
||||
}
|
||||
var interview = EffectiveInterview(definition);
|
||||
return interview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
|
||||
{
|
||||
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
|
||||
throw new ArgumentException("Complete the core interview topics before finishing.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add("finish_interview");
|
||||
attempt.InterviewNodeId = "complete";
|
||||
attempt.OpeningCompleted = true;
|
||||
return;
|
||||
}
|
||||
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("That interview topic has already been covered.");
|
||||
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
|
||||
throw new ArgumentException("That interview topic is not available yet.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add(topic.Id);
|
||||
attempt.InterviewNodeId = topic.Id;
|
||||
}
|
||||
|
||||
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
|
||||
&& definition.InitialInterview.Nodes.Count > 0)
|
||||
return definition.InitialInterview;
|
||||
|
||||
var narrative = definition.InitialChart.HistorySections
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.Text))
|
||||
.Select((value, index) => new
|
||||
{
|
||||
Id = $"history_{index + 1}",
|
||||
Prompt = string.IsNullOrWhiteSpace(value.Title)
|
||||
? "Can you tell me more about your history?"
|
||||
: $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?",
|
||||
value.Text
|
||||
}).ToList();
|
||||
if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History))
|
||||
narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History });
|
||||
|
||||
var nodes = new List<CcsDialogueNode>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true,
|
||||
Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview",
|
||||
Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?",
|
||||
NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete"
|
||||
}]
|
||||
}
|
||||
};
|
||||
for (var index = 0; index < narrative.Count; index++)
|
||||
{
|
||||
var next = index + 1 < narrative.Count ? narrative[index + 1] : null;
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = narrative[index].Id, PatientText = narrative[index].Text,
|
||||
Choices = [new()
|
||||
{
|
||||
Id = next is null ? "finish_interview" : $"ask_history_{index + 2}",
|
||||
Text = next?.Prompt ?? "Is there anything else you would like me to know?",
|
||||
NextNodeId = next?.Id ?? "complete"
|
||||
}]
|
||||
});
|
||||
}
|
||||
nodes.Add(new CcsDialogueNode
|
||||
{
|
||||
Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true
|
||||
});
|
||||
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
|
||||
}
|
||||
|
||||
internal static string MedicalHistory(CcsCaseRevision definition) =>
|
||||
definition.InitialChart.HistorySections.FirstOrDefault(value =>
|
||||
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
|
||||
internal static int Rank(CcsOrderDefinition order, string query)
|
||||
{
|
||||
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 379 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cj8q5fugoyfhq"
|
||||
path="res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/medical-history.svg"
|
||||
dest_files=["res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
|
||||
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b8juqb8k774q4"
|
||||
path="res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/icons/vitals.svg"
|
||||
dest_files=["res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://crss7xdb4asxw"
|
||||
path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/ccs_cases_2/images/computer/computer-base.png"
|
||||
dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"/>
|
||||
<line x1="8" x2="16" y1="21" y2="21"/>
|
||||
<line x1="12" x2="12" y1="17" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://diqn3ntrortw"
|
||||
path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/monitor.svg"
|
||||
dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"orders": [],
|
||||
"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": 2
|
||||
},
|
||||
"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": [],
|
||||
"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." }
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"title": "Peptic Ulcer Disease",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/topics/peptic-ulcer-disease/",
|
||||
"supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
},
|
||||
{
|
||||
"title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations",
|
||||
"publisher": "American College of Gastroenterology",
|
||||
"url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/",
|
||||
"supports": "H. pylori treatment principles and confirmation of eradication",
|
||||
"accessedOn": "2026-08-05",
|
||||
"reviewer": "Question bank editorial review"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"validateOnly": false
|
||||
}
|
||||
@@ -4,6 +4,10 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user.
|
||||
|
||||
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
|
||||
|
||||
Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management.
|
||||
|
||||
UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision.
|
||||
|
||||
## Case structure
|
||||
|
||||
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=8 format=3]
|
||||
[gd_scene load_steps=13 format=3]
|
||||
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"]
|
||||
@@ -6,6 +6,11 @@
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png" id="4_patient"]
|
||||
[ext_resource type="Shader" path="res://shaders/ccs_cases_2/patient_idle.gdshader" id="5_patient_fit"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/chat-bubble.png" id="6_chat_bubble"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/computer/computer-base.png" id="7_computer"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"]
|
||||
[ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/vitals.svg" id="10_vitals"]
|
||||
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/icons/medical-history.svg" id="11_history"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
|
||||
shader = ExtResource("5_patient_fit")
|
||||
@@ -103,15 +108,17 @@ antialiased = true
|
||||
|
||||
[node name="DialogueBubble" type="Control" parent="."]
|
||||
z_index = 30
|
||||
clip_contents = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.51
|
||||
anchor_top = 0.055
|
||||
anchor_right = 0.51
|
||||
anchor_bottom = 0.055
|
||||
offset_right = 420.0
|
||||
offset_bottom = 140.0
|
||||
mouse_filter = 2
|
||||
anchor_left = 0.53
|
||||
anchor_top = 0.04
|
||||
anchor_right = 0.53
|
||||
anchor_bottom = 0.04
|
||||
offset_right = 650.0
|
||||
offset_bottom = 220.0
|
||||
mouse_default_cursor_shape = 2
|
||||
focus_mode = 2
|
||||
|
||||
[node name="Background" type="TextureRect" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
@@ -126,33 +133,349 @@ texture = ExtResource("6_chat_bubble")
|
||||
expand_mode = 1
|
||||
stretch_mode = 0
|
||||
|
||||
[node name="Text" type="Label" parent="DialogueBubble"]
|
||||
[node name="Text" type="RichTextLabel" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 76.0
|
||||
offset_left = 96.0
|
||||
offset_top = 31.0
|
||||
offset_right = -48.0
|
||||
offset_bottom = -43.0
|
||||
offset_right = -64.0
|
||||
offset_bottom = -64.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_colors/font_color = Color(0.055, 0.105, 0.145, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "Hello, doctor."
|
||||
fit_content = false
|
||||
scroll_active = false
|
||||
autowrap_mode = 2
|
||||
visible_characters_behavior = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoices" type="VBoxContainer" parent="."]
|
||||
[node name="ContinueHint" type="Label" parent="DialogueBubble"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 3
|
||||
anchor_left = 1.0
|
||||
anchor_top = 1.0
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -330.0
|
||||
offset_top = -55.0
|
||||
offset_right = -90.0
|
||||
offset_bottom = -27.0
|
||||
grow_horizontal = 0
|
||||
grow_vertical = 0
|
||||
mouse_filter = 2
|
||||
theme_override_colors/font_color = Color(0.12, 0.22, 0.28, 0.82)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "Click to continue"
|
||||
horizontal_alignment = 2
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="DialogueChoiceArea" type="Control" parent="."]
|
||||
z_index = 29
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
anchor_left = 0.5
|
||||
anchors_preset = 12
|
||||
anchor_top = 1.0
|
||||
anchor_right = 0.5
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = -210.0
|
||||
offset_top = -206.0
|
||||
offset_right = 210.0
|
||||
offset_left = 18.0
|
||||
offset_top = -194.0
|
||||
offset_right = -18.0
|
||||
offset_bottom = -4.0
|
||||
theme_override_constants/separation = -8
|
||||
|
||||
[node name="ChoicePager" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 10
|
||||
anchor_right = 1.0
|
||||
offset_bottom = 42.0
|
||||
grow_horizontal = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 1
|
||||
|
||||
[node name="Previous" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Previous"
|
||||
|
||||
[node name="Page" type="Label" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(100, 38)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.94, 0.95, 0.91, 1)
|
||||
theme_override_colors/font_shadow_color = Color(0.02, 0.035, 0.05, 0.9)
|
||||
theme_override_constants/shadow_offset_x = 1
|
||||
theme_override_constants/shadow_offset_y = 2
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "Page 1 of 2"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="Next" type="Button" parent="DialogueChoiceArea/ChoicePager"]
|
||||
custom_minimum_size = Vector2(108, 38)
|
||||
layout_mode = 2
|
||||
text = "Next"
|
||||
|
||||
[node name="DialogueChoices" type="HBoxContainer" parent="DialogueChoiceArea"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_top = 46.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_override_constants/separation = 10
|
||||
alignment = 1
|
||||
|
||||
[node name="ComputerLauncher" type="Button" parent="."]
|
||||
z_index = 40
|
||||
layout_mode = 0
|
||||
offset_left = 24.0
|
||||
offset_top = 24.0
|
||||
offset_right = 188.0
|
||||
offset_bottom = 78.0
|
||||
mouse_default_cursor_shape = 2
|
||||
tooltip_text = "Open simulated computer"
|
||||
icon = ExtResource("8_monitor")
|
||||
icon_max_width = 25
|
||||
expand_icon = true
|
||||
text = "Computer"
|
||||
|
||||
[node name="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="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
|
||||
|
||||
@@ -2,46 +2,139 @@ extends Control
|
||||
|
||||
signal choice_selected(choice_id: String)
|
||||
signal interview_continue_requested
|
||||
signal physical_exam_selected(exam_code: String)
|
||||
signal physical_exam_finish_requested
|
||||
signal debrief_return_requested
|
||||
signal debrief_repeat_requested
|
||||
|
||||
const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png")
|
||||
const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png")
|
||||
const CHOICE_PRESSED := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png")
|
||||
const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0)
|
||||
const CHOICES_PER_PAGE := 4
|
||||
const DIALOGUE_PAGE_MIN_CHARS := 32
|
||||
const DIALOGUE_PAGE_MAX_CHARS := 82
|
||||
const DIALOGUE_BUBBLE_MIN_HEIGHT := 130.0
|
||||
const DIALOGUE_BUBBLE_MAX_HEIGHT := 240.0
|
||||
const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0
|
||||
|
||||
@onready var hour_hand: Node2D = $ClockMount/HourHand
|
||||
@onready var minute_hand: Node2D = $ClockMount/MinuteHand
|
||||
@onready var second_hand: Node2D = $ClockMount/SecondHand
|
||||
@onready var patient: TextureRect = $Patient
|
||||
@onready var dialogue_bubble: Control = $DialogueBubble
|
||||
@onready var dialogue_text: Label = $DialogueBubble/Text
|
||||
@onready var choice_container: VBoxContainer = $DialogueChoices
|
||||
@onready var dialogue_text: RichTextLabel = $DialogueBubble/Text
|
||||
@onready var dialogue_continue_hint: Label = $DialogueBubble/ContinueHint
|
||||
@onready var choice_area: Control = $DialogueChoiceArea
|
||||
@onready var choice_container: HBoxContainer = $DialogueChoiceArea/DialogueChoices
|
||||
@onready var choice_pager: HBoxContainer = $DialogueChoiceArea/ChoicePager
|
||||
@onready var previous_page_button: Button = $DialogueChoiceArea/ChoicePager/Previous
|
||||
@onready var page_label: Label = $DialogueChoiceArea/ChoicePager/Page
|
||||
@onready var next_page_button: Button = $DialogueChoiceArea/ChoicePager/Next
|
||||
@onready var computer_launcher: Button = $ComputerLauncher
|
||||
@onready var computer_overlay: Control = $ComputerOverlay
|
||||
@onready var computer_close_button: Button = $ComputerOverlay/Close
|
||||
@onready var vitals_launcher: Button = $ComputerOverlay/VitalsLauncher
|
||||
@onready var vitals_window: PanelContainer = $ComputerOverlay/VitalsWindow
|
||||
@onready var vitals_close_button: Button = $ComputerOverlay/VitalsWindow/Margin/Content/Header/Close
|
||||
@onready var vitals_header: HBoxContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Header
|
||||
@onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows
|
||||
@onready var vitals_empty: Label = $ComputerOverlay/VitalsWindow/Margin/Content/Empty
|
||||
@onready var medical_history_launcher: Button = $ComputerOverlay/MedicalHistoryLauncher
|
||||
@onready var medical_history_window: PanelContainer = $ComputerOverlay/MedicalHistoryWindow
|
||||
@onready var medical_history_header: HBoxContainer = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header
|
||||
@onready var medical_history_close_button: Button = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header/Close
|
||||
@onready var medical_history_text: Label = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Text
|
||||
|
||||
var blink_tween: Tween
|
||||
var dialogue_tween: Tween
|
||||
var clock_seconds := 9.0 * 60.0 * 60.0
|
||||
var debrief_panel: PanelContainer
|
||||
var physical_exam_panel: PanelContainer
|
||||
var physical_exam_grid: GridContainer
|
||||
var physical_exam_finding_title: Label
|
||||
var physical_exam_finding_text: Label
|
||||
var physical_exam_status: Label
|
||||
var physical_exam_finish_button: Button
|
||||
var physical_exam_buttons := {}
|
||||
var experience_mode := "management"
|
||||
var pending_choices: Array = []
|
||||
var available_choices: Array = []
|
||||
var choice_page := 0
|
||||
var dialogue_pages: Array[String] = []
|
||||
var dialogue_page_index := 0
|
||||
var dialogue_characters_per_second := 35.0
|
||||
var dialogue_reveal_complete := false
|
||||
var dialogue_layout_generation := 0
|
||||
var dialogue_input_enabled := true
|
||||
var computer_available := true
|
||||
var computer_drag_window: Control
|
||||
var computer_drag_offset := Vector2.ZERO
|
||||
var computer_window_z := 2
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
previous_page_button.pressed.connect(_change_choice_page.bind(-1))
|
||||
next_page_button.pressed.connect(_change_choice_page.bind(1))
|
||||
computer_launcher.pressed.connect(open_computer)
|
||||
computer_close_button.pressed.connect(close_computer)
|
||||
vitals_launcher.pressed.connect(open_vitals)
|
||||
vitals_close_button.pressed.connect(close_vitals)
|
||||
medical_history_launcher.pressed.connect(open_medical_history)
|
||||
medical_history_close_button.pressed.connect(close_medical_history)
|
||||
vitals_header.gui_input.connect(_on_computer_window_header_input.bind(vitals_window, vitals_close_button))
|
||||
medical_history_header.gui_input.connect(_on_computer_window_header_input.bind(
|
||||
medical_history_window, medical_history_close_button))
|
||||
vitals_window.gui_input.connect(_on_computer_window_input.bind(vitals_window))
|
||||
medical_history_window.gui_input.connect(_on_computer_window_input.bind(medical_history_window))
|
||||
resized.connect(_resize_computer_windows)
|
||||
_style_vitals_window()
|
||||
_style_computer_window(medical_history_window)
|
||||
_style_desktop_shortcut(vitals_launcher)
|
||||
_style_desktop_shortcut(medical_history_launcher)
|
||||
_resize_computer_windows()
|
||||
_update_clock()
|
||||
_schedule_patient_blink()
|
||||
show_patient_dialogue("Loading patient interview...")
|
||||
hide_patient_dialogue()
|
||||
hide_dialogue_choices()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_clock()
|
||||
pass
|
||||
|
||||
|
||||
func _update_clock() -> void:
|
||||
var time := Time.get_time_dict_from_system()
|
||||
var unix_time := Time.get_unix_time_from_system()
|
||||
var second := float(time.second) + fmod(unix_time, 1.0)
|
||||
var minute := float(time.minute) + second / 60.0
|
||||
var hour := float(time.hour % 12) + minute / 60.0
|
||||
var hour_value := floori(clock_seconds / 3600.0)
|
||||
var minute_value := floori(fmod(clock_seconds, 3600.0) / 60.0)
|
||||
var second_value := fmod(clock_seconds, 60.0)
|
||||
var second := float(second_value)
|
||||
var minute := float(minute_value) + second / 60.0
|
||||
var hour := float(hour_value % 12) + minute / 60.0
|
||||
hour_hand.rotation = TAU * hour / 12.0
|
||||
minute_hand.rotation = TAU * minute / 60.0
|
||||
second_hand.rotation = TAU * second / 60.0
|
||||
|
||||
|
||||
func configure_presentation(
|
||||
presentation: Dictionary, simulated_start: String, simulated_minute: int,
|
||||
vitals: Dictionary = {}, medical_history: String = "") -> void:
|
||||
if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
|
||||
push_warning("Unsupported CCS patient asset key; using the office default.")
|
||||
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false)
|
||||
if not date_time.is_empty():
|
||||
clock_seconds = float(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60)
|
||||
clock_seconds += float(simulated_minute * 60)
|
||||
_update_clock()
|
||||
_render_vitals(vitals)
|
||||
medical_history_text.text = medical_history.strip_edges() \
|
||||
if not medical_history.strip_edges().is_empty() \
|
||||
else "No past medical history is available."
|
||||
|
||||
|
||||
func configure_experience_mode(value: String) -> void:
|
||||
experience_mode = value
|
||||
|
||||
|
||||
func _schedule_patient_blink() -> void:
|
||||
blink_tween = create_tween()
|
||||
blink_tween.tween_interval(randf_range(2.8, 5.2))
|
||||
@@ -62,57 +155,483 @@ func _set_patient_blink(amount: float) -> void:
|
||||
func show_patient_dialogue(text: String, characters_per_second: float = 35.0) -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
dialogue_text.text = text
|
||||
dialogue_pages = _split_dialogue_pages(text)
|
||||
dialogue_page_index = 0
|
||||
dialogue_characters_per_second = characters_per_second
|
||||
_show_dialogue_page()
|
||||
|
||||
|
||||
func _show_dialogue_page() -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
if dialogue_pages.is_empty():
|
||||
complete_response_transition()
|
||||
return
|
||||
dialogue_text.text = dialogue_pages[dialogue_page_index]
|
||||
dialogue_text.visible_ratio = 0.0
|
||||
dialogue_reveal_complete = false
|
||||
dialogue_continue_hint.text = "Click to continue"
|
||||
dialogue_continue_hint.visible = true
|
||||
dialogue_bubble.visible = true
|
||||
_resize_dialogue_bubble()
|
||||
var reveal_duration := float(text.length()) / maxf(characters_per_second, 1.0)
|
||||
dialogue_bubble.grab_focus()
|
||||
dialogue_layout_generation += 1
|
||||
call_deferred("_prepare_dialogue_page", dialogue_layout_generation)
|
||||
|
||||
|
||||
func _prepare_dialogue_page(generation: int) -> void:
|
||||
if generation != dialogue_layout_generation or not dialogue_bubble.visible \
|
||||
or dialogue_reveal_complete:
|
||||
return
|
||||
var measured_height := dialogue_text.get_content_height() + DIALOGUE_BUBBLE_VERTICAL_CHROME
|
||||
dialogue_bubble.size.y = clampf(
|
||||
measured_height, DIALOGUE_BUBBLE_MIN_HEIGHT, DIALOGUE_BUBBLE_MAX_HEIGHT)
|
||||
var reveal_duration := float(dialogue_text.text.length()) / maxf(dialogue_characters_per_second, 1.0)
|
||||
dialogue_tween = create_tween()
|
||||
dialogue_tween.tween_property(dialogue_text, "visible_ratio", 1.0, reveal_duration)
|
||||
dialogue_tween.finished.connect(_on_dialogue_reveal_finished)
|
||||
|
||||
|
||||
func hide_patient_dialogue() -> void:
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
dialogue_layout_generation += 1
|
||||
dialogue_bubble.visible = false
|
||||
dialogue_continue_hint.visible = false
|
||||
dialogue_pages = []
|
||||
dialogue_page_index = 0
|
||||
dialogue_reveal_complete = false
|
||||
pending_choices = []
|
||||
|
||||
|
||||
func is_patient_dialogue_visible() -> bool:
|
||||
return dialogue_bubble.visible
|
||||
|
||||
|
||||
func _resize_dialogue_bubble() -> void:
|
||||
# Label wrapping is calculated from the fixed text width immediately after
|
||||
# assignment, so explicit newlines and automatic wraps both affect height.
|
||||
var line_count := maxi(dialogue_text.get_line_count(), 1)
|
||||
var target_height := clampf(110.0 + line_count * 30.0, 140.0, 320.0)
|
||||
dialogue_bubble.size.y = target_height
|
||||
|
||||
|
||||
func configure_interview(node: Dictionary) -> void:
|
||||
show_patient_dialogue(str(node.get("patientText", "")))
|
||||
computer_available = true
|
||||
computer_launcher.visible = not computer_overlay.visible
|
||||
if debrief_panel != null and is_instance_valid(debrief_panel):
|
||||
debrief_panel.queue_free()
|
||||
debrief_panel = null
|
||||
var next_choices: Array
|
||||
if bool(node.get("isTerminal", false)):
|
||||
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
|
||||
next_choices = [{
|
||||
"id": "__continue",
|
||||
"text": "Begin Physical Examination" if experience_mode == "graphical_interview" else "Continue to Patient Management"
|
||||
}]
|
||||
else:
|
||||
show_dialogue_choices(node.get("choices", []))
|
||||
next_choices = node.get("choices", [])
|
||||
hide_dialogue_choices()
|
||||
if bool(node.get("suppressPatientText", false)):
|
||||
hide_patient_dialogue()
|
||||
show_dialogue_choices(next_choices)
|
||||
return
|
||||
pending_choices = next_choices
|
||||
show_patient_dialogue(str(node.get("patientText", "")))
|
||||
|
||||
|
||||
func complete_dialogue_reveal() -> void:
|
||||
if dialogue_reveal_complete:
|
||||
return
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.kill()
|
||||
dialogue_layout_generation += 1
|
||||
dialogue_text.visible_ratio = 1.0
|
||||
_on_dialogue_reveal_finished()
|
||||
|
||||
|
||||
func _on_dialogue_reveal_finished() -> void:
|
||||
dialogue_tween = null
|
||||
dialogue_reveal_complete = true
|
||||
dialogue_continue_hint.text = "Click to continue"
|
||||
dialogue_continue_hint.visible = true
|
||||
|
||||
|
||||
func advance_dialogue() -> void:
|
||||
if not dialogue_bubble.visible:
|
||||
return
|
||||
if not dialogue_reveal_complete:
|
||||
complete_dialogue_reveal()
|
||||
return
|
||||
if dialogue_page_index < dialogue_pages.size() - 1:
|
||||
dialogue_page_index += 1
|
||||
_show_dialogue_page()
|
||||
return
|
||||
complete_response_transition()
|
||||
|
||||
|
||||
func complete_response_transition() -> void:
|
||||
var choices := pending_choices
|
||||
pending_choices = []
|
||||
dialogue_bubble.visible = false
|
||||
dialogue_continue_hint.visible = false
|
||||
dialogue_pages = []
|
||||
dialogue_page_index = 0
|
||||
dialogue_reveal_complete = false
|
||||
show_dialogue_choices(choices)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if computer_overlay.visible:
|
||||
return
|
||||
if _event_targets_computer_launcher(event):
|
||||
return
|
||||
if not dialogue_input_enabled or not dialogue_bubble.visible:
|
||||
return
|
||||
var should_advance := false
|
||||
if event is InputEventMouseButton:
|
||||
should_advance = event.button_index == MOUSE_BUTTON_LEFT and event.pressed \
|
||||
and get_global_rect().has_point(event.position)
|
||||
elif event is InputEventScreenTouch:
|
||||
should_advance = event.pressed and get_global_rect().has_point(event.position)
|
||||
elif event is InputEventKey:
|
||||
should_advance = event.pressed and not event.echo \
|
||||
and event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]
|
||||
if not should_advance:
|
||||
return
|
||||
advance_dialogue()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func set_dialogue_input_enabled(enabled: bool) -> void:
|
||||
dialogue_input_enabled = enabled
|
||||
|
||||
|
||||
func open_computer() -> void:
|
||||
if not computer_available or computer_overlay.visible:
|
||||
return
|
||||
computer_overlay.visible = true
|
||||
computer_launcher.visible = false
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.pause()
|
||||
computer_close_button.grab_focus()
|
||||
|
||||
|
||||
func close_computer() -> bool:
|
||||
if not computer_overlay.visible:
|
||||
return false
|
||||
close_vitals()
|
||||
close_medical_history()
|
||||
computer_overlay.visible = false
|
||||
computer_launcher.visible = computer_available
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
dialogue_tween.play()
|
||||
return true
|
||||
|
||||
|
||||
func is_computer_open() -> bool:
|
||||
return computer_overlay.visible
|
||||
|
||||
|
||||
func close_transient_overlay() -> bool:
|
||||
var top_window := _topmost_computer_window()
|
||||
if top_window == vitals_window:
|
||||
return close_vitals()
|
||||
if top_window == medical_history_window:
|
||||
return close_medical_history()
|
||||
return close_computer()
|
||||
|
||||
|
||||
func open_vitals() -> void:
|
||||
if not computer_overlay.visible:
|
||||
return
|
||||
vitals_window.visible = true
|
||||
_bring_computer_window_to_front(vitals_window)
|
||||
vitals_close_button.grab_focus()
|
||||
|
||||
|
||||
func close_vitals() -> bool:
|
||||
if not vitals_window.visible:
|
||||
return false
|
||||
if computer_drag_window == vitals_window:
|
||||
computer_drag_window = null
|
||||
vitals_window.visible = false
|
||||
return true
|
||||
|
||||
|
||||
func is_vitals_open() -> bool:
|
||||
return vitals_window.visible
|
||||
|
||||
|
||||
func open_medical_history() -> void:
|
||||
if not computer_overlay.visible:
|
||||
return
|
||||
medical_history_window.visible = true
|
||||
_bring_computer_window_to_front(medical_history_window)
|
||||
medical_history_close_button.grab_focus()
|
||||
|
||||
|
||||
func close_medical_history() -> bool:
|
||||
if not medical_history_window.visible:
|
||||
return false
|
||||
if computer_drag_window == medical_history_window:
|
||||
computer_drag_window = null
|
||||
medical_history_window.visible = false
|
||||
return true
|
||||
|
||||
|
||||
func is_medical_history_open() -> bool:
|
||||
return medical_history_window.visible
|
||||
|
||||
|
||||
func _render_vitals(vitals: Dictionary) -> void:
|
||||
for child in vitals_rows.get_children():
|
||||
vitals_rows.remove_child(child)
|
||||
child.queue_free()
|
||||
var rows := _build_vital_rows(vitals)
|
||||
for row in rows:
|
||||
var name_label := Label.new()
|
||||
name_label.custom_minimum_size.x = 230.0
|
||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_label.add_theme_font_size_override("font_size", 19)
|
||||
name_label.add_theme_color_override("font_color", Color(0.18, 0.25, 0.31))
|
||||
name_label.text = "%s:" % str(row.get("label", "Vital sign"))
|
||||
vitals_rows.add_child(name_label)
|
||||
var value_label := Label.new()
|
||||
value_label.custom_minimum_size.x = 230.0
|
||||
value_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
value_label.add_theme_font_size_override("font_size", 19)
|
||||
value_label.add_theme_color_override("font_color", Color(0.04, 0.09, 0.13))
|
||||
value_label.text = str(row.get("value", ""))
|
||||
value_label.autowrap_mode = TextServer.AUTOWRAP_OFF
|
||||
value_label.clip_text = true
|
||||
vitals_rows.add_child(value_label)
|
||||
vitals_rows.visible = not rows.is_empty()
|
||||
vitals_empty.visible = rows.is_empty()
|
||||
_resize_computer_windows()
|
||||
|
||||
|
||||
func _build_vital_rows(vitals: Dictionary) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
var displayed := {}
|
||||
_append_vital(rows, displayed, vitals, "temperature", "Temperature")
|
||||
_append_vital(rows, displayed, vitals, "pulse", "Pulse")
|
||||
_append_vital(rows, displayed, vitals, "respirations", "Respiratory rate")
|
||||
var blood_pressure := str(vitals.get("bloodPressure", "")).strip_edges()
|
||||
if not blood_pressure.is_empty():
|
||||
displayed["bloodPressure"] = true
|
||||
var pattern := RegEx.create_from_string("^\\s*([^/]+)\\s*/\\s*([0-9.]+)(.*)$")
|
||||
var matched := pattern.search(blood_pressure)
|
||||
if matched:
|
||||
var suffix := matched.get_string(3).strip_edges()
|
||||
var unit_suffix := " %s" % suffix if not suffix.is_empty() else ""
|
||||
rows.append({"label": "Blood pressure, systolic", "value": matched.get_string(1).strip_edges() + unit_suffix})
|
||||
rows.append({"label": "Blood pressure, diastolic", "value": matched.get_string(2).strip_edges() + unit_suffix})
|
||||
else:
|
||||
rows.append({"label": "Blood pressure", "value": blood_pressure})
|
||||
_append_vital(rows, displayed, vitals, "oxygenSaturation", "Oxygen saturation")
|
||||
_append_vital(rows, displayed, vitals, "height", "Height")
|
||||
_append_vital(rows, displayed, vitals, "weight", "Weight")
|
||||
_append_vital(rows, displayed, vitals, "bmi", "Body Mass Index")
|
||||
var remaining_keys: Array = vitals.keys()
|
||||
remaining_keys.sort()
|
||||
for raw_key in remaining_keys:
|
||||
var key := str(raw_key)
|
||||
if not displayed.has(key):
|
||||
_append_vital(rows, displayed, vitals, key, _friendly_vital_name(key))
|
||||
return rows
|
||||
|
||||
|
||||
func _append_vital(
|
||||
rows: Array[Dictionary], displayed: Dictionary, vitals: Dictionary,
|
||||
key: String, label: String) -> void:
|
||||
displayed[key] = true
|
||||
var value := str(vitals.get(key, "")).strip_edges()
|
||||
if not value.is_empty():
|
||||
rows.append({"label": label, "value": value})
|
||||
|
||||
|
||||
func _friendly_vital_name(key: String) -> String:
|
||||
var result := ""
|
||||
for index in key.length():
|
||||
var character := key[index]
|
||||
if index > 0 and character == character.to_upper() and character != character.to_lower():
|
||||
result += " "
|
||||
result += character
|
||||
return result.capitalize()
|
||||
|
||||
|
||||
func _style_vitals_window() -> void:
|
||||
_style_computer_window(vitals_window)
|
||||
|
||||
|
||||
func _style_computer_window(window: PanelContainer) -> void:
|
||||
var panel_style := StyleBoxFlat.new()
|
||||
panel_style.bg_color = Color(0.97, 0.98, 0.99, 0.98)
|
||||
panel_style.border_color = Color(0.12, 0.24, 0.34, 0.9)
|
||||
panel_style.set_border_width_all(3)
|
||||
panel_style.set_corner_radius_all(14)
|
||||
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35)
|
||||
panel_style.shadow_size = 18
|
||||
window.add_theme_stylebox_override("panel", panel_style)
|
||||
|
||||
|
||||
func _style_desktop_shortcut(button: Button) -> void:
|
||||
var normal := StyleBoxEmpty.new()
|
||||
var hover := StyleBoxFlat.new()
|
||||
hover.bg_color = Color(0.82, 0.91, 0.97, 0.18)
|
||||
hover.border_color = Color(0.9, 0.96, 1.0, 0.5)
|
||||
hover.set_border_width_all(1)
|
||||
hover.set_corner_radius_all(8)
|
||||
var pressed := hover.duplicate() as StyleBoxFlat
|
||||
pressed.bg_color = Color(0.72, 0.86, 0.96, 0.28)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", pressed)
|
||||
button.add_theme_stylebox_override("focus", hover)
|
||||
|
||||
|
||||
func _resize_computer_windows() -> void:
|
||||
if vitals_window == null or medical_history_window == null:
|
||||
return
|
||||
var vital_row_count := vitals_rows.get_child_count() / 2
|
||||
var vitals_content_height := clampf(180.0 + float(vital_row_count) * 36.0, 300.0, 540.0)
|
||||
var vitals_size := Vector2(
|
||||
minf(720.0, maxf(320.0, size.x - 48.0)),
|
||||
minf(vitals_content_height, maxf(300.0, size.y - 48.0)))
|
||||
_set_centered_window_rect(vitals_window, vitals_size, Vector2(-55.0, -25.0))
|
||||
var history_size := Vector2(
|
||||
minf(560.0, maxf(320.0, size.x - 48.0)),
|
||||
minf(300.0, maxf(260.0, size.y - 48.0)))
|
||||
_set_centered_window_rect(medical_history_window, history_size, Vector2(55.0, 25.0))
|
||||
|
||||
|
||||
func _set_centered_window_rect(window: Control, window_size: Vector2, offset: Vector2) -> void:
|
||||
window.offset_left = -window_size.x * 0.5 + offset.x
|
||||
window.offset_top = -window_size.y * 0.5 + offset.y
|
||||
window.offset_right = window_size.x * 0.5 + offset.x
|
||||
window.offset_bottom = window_size.y * 0.5 + offset.y
|
||||
_set_computer_window_position(window, window.position)
|
||||
|
||||
|
||||
func _on_computer_window_header_input(
|
||||
event: InputEvent, window: Control, close_button: Button) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if event.pressed:
|
||||
if close_button.get_global_rect().has_point(event.global_position):
|
||||
return
|
||||
computer_drag_window = window
|
||||
computer_drag_offset = event.global_position - window.global_position
|
||||
_bring_computer_window_to_front(window)
|
||||
else:
|
||||
computer_drag_window = null
|
||||
window.accept_event()
|
||||
elif event is InputEventMouseMotion and computer_drag_window == window:
|
||||
var target_global: Vector2 = event.global_position - computer_drag_offset
|
||||
_set_computer_window_position(window, target_global - global_position)
|
||||
window.accept_event()
|
||||
|
||||
|
||||
func _on_computer_window_input(event: InputEvent, window: Control) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
_bring_computer_window_to_front(window)
|
||||
|
||||
|
||||
func _set_vitals_window_position(target: Vector2) -> void:
|
||||
_set_computer_window_position(vitals_window, target)
|
||||
|
||||
|
||||
func _set_medical_history_window_position(target: Vector2) -> void:
|
||||
_set_computer_window_position(medical_history_window, target)
|
||||
|
||||
|
||||
func _set_computer_window_position(window: Control, target: Vector2) -> void:
|
||||
var maximum := Vector2(
|
||||
maxf(0.0, size.x - window.size.x),
|
||||
maxf(0.0, size.y - window.size.y))
|
||||
window.position = Vector2(
|
||||
clampf(target.x, 0.0, maximum.x),
|
||||
clampf(target.y, 0.0, maximum.y))
|
||||
|
||||
|
||||
func _bring_computer_window_to_front(window: Control) -> void:
|
||||
computer_window_z += 1
|
||||
window.z_index = computer_window_z
|
||||
|
||||
|
||||
func _topmost_computer_window() -> Control:
|
||||
var top_window: Control
|
||||
for window in [vitals_window, medical_history_window]:
|
||||
if window.visible and (top_window == null or window.z_index > top_window.z_index):
|
||||
top_window = window
|
||||
return top_window
|
||||
|
||||
|
||||
func _event_targets_computer_launcher(event: InputEvent) -> bool:
|
||||
if not computer_launcher.visible:
|
||||
return false
|
||||
if event is InputEventMouseButton:
|
||||
return event.button_index == MOUSE_BUTTON_LEFT \
|
||||
and computer_launcher.get_global_rect().has_point(event.position)
|
||||
if event is InputEventScreenTouch:
|
||||
return computer_launcher.get_global_rect().has_point(event.position)
|
||||
return false
|
||||
|
||||
|
||||
func _split_dialogue_pages(text: String) -> Array[String]:
|
||||
var normalized := text.strip_edges()
|
||||
if normalized.is_empty():
|
||||
return []
|
||||
var pages: Array[String] = []
|
||||
var current := ""
|
||||
for word in normalized.split(" ", false):
|
||||
var candidate := word if current.is_empty() else "%s %s" % [current, word]
|
||||
var has_natural_break := current.length() >= DIALOGUE_PAGE_MIN_CHARS \
|
||||
and _ends_dialogue_phrase(word)
|
||||
if candidate.length() > DIALOGUE_PAGE_MAX_CHARS and not current.is_empty():
|
||||
pages.append(current)
|
||||
current = word
|
||||
elif has_natural_break:
|
||||
pages.append(candidate)
|
||||
current = ""
|
||||
else:
|
||||
current = candidate
|
||||
if not current.is_empty():
|
||||
pages.append(current)
|
||||
return pages
|
||||
|
||||
|
||||
func _ends_dialogue_phrase(word: String) -> bool:
|
||||
return word.ends_with(",") or word.ends_with(";") or word.ends_with(":") \
|
||||
or word.ends_with(".") or word.ends_with("?") or word.ends_with("!")
|
||||
|
||||
|
||||
func set_choices_pending(pending: bool) -> void:
|
||||
for child in choice_container.get_children():
|
||||
if child is BaseButton:
|
||||
child.disabled = pending
|
||||
previous_page_button.disabled = pending or choice_page <= 0
|
||||
next_page_button.disabled = pending or choice_page >= _choice_page_count() - 1
|
||||
|
||||
|
||||
func show_dialogue_choices(options: Array) -> void:
|
||||
hide_dialogue_choices()
|
||||
var stack_height := options.size() * 97.0 + 8.0
|
||||
choice_container.offset_top = -4.0 - stack_height
|
||||
for index in options.size():
|
||||
var option: Dictionary = options[index]
|
||||
available_choices = options.duplicate(true)
|
||||
choice_page = 0
|
||||
_render_choice_page()
|
||||
|
||||
|
||||
func _render_choice_page() -> void:
|
||||
_clear_choice_buttons()
|
||||
var page_count := _choice_page_count()
|
||||
if page_count == 0:
|
||||
choice_area.visible = false
|
||||
return
|
||||
choice_page = clampi(choice_page, 0, page_count - 1)
|
||||
var start_index := choice_page * CHOICES_PER_PAGE
|
||||
var end_index := mini(start_index + CHOICES_PER_PAGE, available_choices.size())
|
||||
var visible_choice_count := end_index - start_index
|
||||
for index in range(start_index, end_index):
|
||||
var option: Dictionary = available_choices[index]
|
||||
var choice_id := str(option.get("id", ""))
|
||||
var option_text := str(option.get("text", ""))
|
||||
var button := TextureButton.new()
|
||||
button.custom_minimum_size = Vector2(420.0, 105.0)
|
||||
button.set_meta("choice_id", choice_id)
|
||||
button.custom_minimum_size = Vector2(
|
||||
0.0 if visible_choice_count == CHOICES_PER_PAGE else 350.0, 136.0)
|
||||
button.size_flags_horizontal = (
|
||||
Control.SIZE_EXPAND_FILL if visible_choice_count == CHOICES_PER_PAGE
|
||||
else Control.SIZE_SHRINK_CENTER)
|
||||
button.ignore_texture_size = true
|
||||
button.stretch_mode = TextureButton.STRETCH_SCALE
|
||||
button.texture_normal = _crop_choice_texture(CHOICE_NORMAL)
|
||||
@@ -129,25 +648,57 @@ func show_dialogue_choices(options: Array) -> void:
|
||||
|
||||
var label := Label.new()
|
||||
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
label.offset_left = 48.0
|
||||
label.offset_top = 14.0
|
||||
label.offset_right = -48.0
|
||||
label.offset_bottom = -14.0
|
||||
label.offset_left = 28.0
|
||||
label.offset_top = 12.0
|
||||
label.offset_right = -28.0
|
||||
label.offset_bottom = -12.0
|
||||
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
label.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
|
||||
label.add_theme_font_size_override("font_size", 19)
|
||||
label.add_theme_font_size_override("font_size", _choice_font_size(option_text))
|
||||
label.text = option_text
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.clip_text = true
|
||||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
button.add_child(label)
|
||||
choice_container.add_child(button)
|
||||
choice_container.visible = not options.is_empty()
|
||||
choice_pager.visible = page_count > 1
|
||||
page_label.text = "Page %d of %d" % [choice_page + 1, page_count]
|
||||
previous_page_button.disabled = choice_page <= 0
|
||||
next_page_button.disabled = choice_page >= page_count - 1
|
||||
choice_area.visible = true
|
||||
choice_container.visible = true
|
||||
|
||||
|
||||
func hide_dialogue_choices() -> void:
|
||||
for child in choice_container.get_children():
|
||||
child.queue_free()
|
||||
_clear_choice_buttons()
|
||||
available_choices = []
|
||||
choice_page = 0
|
||||
choice_container.visible = false
|
||||
choice_pager.visible = false
|
||||
choice_area.visible = false
|
||||
|
||||
|
||||
func _clear_choice_buttons() -> void:
|
||||
for child in choice_container.get_children():
|
||||
choice_container.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
|
||||
func _choice_page_count() -> int:
|
||||
return ceili(float(available_choices.size()) / float(CHOICES_PER_PAGE))
|
||||
|
||||
|
||||
func _change_choice_page(direction: int) -> void:
|
||||
choice_page = clampi(choice_page + direction, 0, maxi(_choice_page_count() - 1, 0))
|
||||
_render_choice_page()
|
||||
|
||||
|
||||
func _choice_font_size(text: String) -> int:
|
||||
if text.length() > 105:
|
||||
return 15
|
||||
if text.length() > 75:
|
||||
return 16
|
||||
return 17
|
||||
|
||||
|
||||
func _crop_choice_texture(source: Texture2D) -> AtlasTexture:
|
||||
@@ -175,3 +726,259 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void:
|
||||
interview_continue_requested.emit()
|
||||
else:
|
||||
choice_selected.emit(choice_id)
|
||||
|
||||
|
||||
func show_physical_exam(exams: Array, chart: Array, pending_code: String = "") -> void:
|
||||
hide_patient_dialogue()
|
||||
hide_dialogue_choices()
|
||||
computer_available = true
|
||||
computer_launcher.visible = not computer_overlay.visible
|
||||
_ensure_physical_exam_panel()
|
||||
physical_exam_panel.visible = true
|
||||
for child in physical_exam_grid.get_children():
|
||||
physical_exam_grid.remove_child(child)
|
||||
child.queue_free()
|
||||
physical_exam_buttons.clear()
|
||||
var completed := {}
|
||||
for raw_entry in chart:
|
||||
var entry: Dictionary = raw_entry
|
||||
completed[str(entry.get("code", ""))] = entry
|
||||
var exam_codes := {}
|
||||
for raw_exam in exams:
|
||||
var exam: Dictionary = raw_exam
|
||||
var code := str(exam.get("code", ""))
|
||||
if code.is_empty():
|
||||
continue
|
||||
exam_codes[code] = true
|
||||
var button := Button.new()
|
||||
var is_complete := completed.has(code)
|
||||
button.custom_minimum_size = Vector2(0.0, 48.0)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.text = "%s%s" % ["[Done] " if is_complete else "", str(exam.get("name", code.capitalize()))]
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.disabled = is_complete or not pending_code.is_empty()
|
||||
button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
button.pressed.connect(_on_physical_exam_pressed.bind(code))
|
||||
_style_physical_exam_button(button, is_complete)
|
||||
physical_exam_grid.add_child(button)
|
||||
physical_exam_buttons[code] = button
|
||||
var latest_finding: Dictionary = {}
|
||||
for index in range(chart.size() - 1, -1, -1):
|
||||
var entry: Dictionary = chart[index]
|
||||
if exam_codes.has(str(entry.get("code", ""))):
|
||||
latest_finding = entry
|
||||
break
|
||||
if latest_finding.is_empty():
|
||||
physical_exam_finding_title.text = "Examination Findings"
|
||||
physical_exam_finding_text.text = "Select an examination component to perform it and review the finding."
|
||||
else:
|
||||
physical_exam_finding_title.text = str(latest_finding.get("title", "Examination Finding"))
|
||||
physical_exam_finding_text.text = str(latest_finding.get("text", "No finding was reported."))
|
||||
set_physical_exam_pending(pending_code)
|
||||
|
||||
|
||||
func set_physical_exam_pending(exam_code: String) -> void:
|
||||
var pending := not exam_code.is_empty()
|
||||
for code in physical_exam_buttons:
|
||||
var button: Button = physical_exam_buttons[code]
|
||||
button.disabled = pending or button.text.begins_with("[Done]")
|
||||
physical_exam_finish_button.disabled = pending
|
||||
physical_exam_status.text = "Performing %s..." % _physical_exam_name(exam_code) if pending else ""
|
||||
|
||||
|
||||
func is_physical_exam_visible() -> bool:
|
||||
return physical_exam_panel != null and physical_exam_panel.visible
|
||||
|
||||
|
||||
func show_physical_exam_error(message: String) -> void:
|
||||
set_physical_exam_pending("")
|
||||
physical_exam_status.text = message
|
||||
|
||||
|
||||
func _ensure_physical_exam_panel() -> void:
|
||||
if physical_exam_panel != null and is_instance_valid(physical_exam_panel):
|
||||
return
|
||||
physical_exam_panel = PanelContainer.new()
|
||||
physical_exam_panel.name = "PhysicalExamination"
|
||||
physical_exam_panel.z_index = 50
|
||||
add_child(physical_exam_panel)
|
||||
physical_exam_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
physical_exam_panel.offset_left = 44.0
|
||||
physical_exam_panel.offset_top = 28.0
|
||||
physical_exam_panel.offset_right = -44.0
|
||||
physical_exam_panel.offset_bottom = -28.0
|
||||
var panel_style := StyleBoxFlat.new()
|
||||
panel_style.bg_color = Color(0.96, 0.97, 0.98, 0.97)
|
||||
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.9)
|
||||
panel_style.set_border_width_all(2)
|
||||
panel_style.set_corner_radius_all(14)
|
||||
physical_exam_panel.add_theme_stylebox_override("panel", panel_style)
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 26)
|
||||
margin.add_theme_constant_override("margin_top", 22)
|
||||
margin.add_theme_constant_override("margin_right", 26)
|
||||
margin.add_theme_constant_override("margin_bottom", 22)
|
||||
physical_exam_panel.add_child(margin)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(content)
|
||||
var heading := Label.new()
|
||||
heading.text = "Physical Examination"
|
||||
heading.add_theme_font_size_override("font_size", 30)
|
||||
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
|
||||
content.add_child(heading)
|
||||
var instructions := Label.new()
|
||||
instructions.text = "Select one component at a time. Completed examinations are marked below."
|
||||
instructions.add_theme_font_size_override("font_size", 17)
|
||||
instructions.add_theme_color_override("font_color", Color(0.18, 0.25, 0.31))
|
||||
content.add_child(instructions)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
content.add_child(scroll)
|
||||
physical_exam_grid = GridContainer.new()
|
||||
physical_exam_grid.name = "ExamComponents"
|
||||
physical_exam_grid.columns = 2
|
||||
physical_exam_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
physical_exam_grid.add_theme_constant_override("h_separation", 12)
|
||||
physical_exam_grid.add_theme_constant_override("v_separation", 8)
|
||||
scroll.add_child(physical_exam_grid)
|
||||
var divider := HSeparator.new()
|
||||
content.add_child(divider)
|
||||
physical_exam_finding_title = Label.new()
|
||||
physical_exam_finding_title.add_theme_font_size_override("font_size", 21)
|
||||
physical_exam_finding_title.add_theme_color_override("font_color", Color(0.08, 0.19, 0.27))
|
||||
content.add_child(physical_exam_finding_title)
|
||||
physical_exam_finding_text = Label.new()
|
||||
physical_exam_finding_text.custom_minimum_size.y = 64.0
|
||||
physical_exam_finding_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
physical_exam_finding_text.add_theme_font_size_override("font_size", 17)
|
||||
physical_exam_finding_text.add_theme_color_override("font_color", Color(0.06, 0.1, 0.14))
|
||||
content.add_child(physical_exam_finding_text)
|
||||
var actions := HBoxContainer.new()
|
||||
actions.alignment = BoxContainer.ALIGNMENT_END
|
||||
actions.add_theme_constant_override("separation", 12)
|
||||
content.add_child(actions)
|
||||
physical_exam_status = Label.new()
|
||||
physical_exam_status.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
physical_exam_status.add_theme_font_size_override("font_size", 16)
|
||||
physical_exam_status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
actions.add_child(physical_exam_status)
|
||||
physical_exam_finish_button = Button.new()
|
||||
physical_exam_finish_button.text = "Finish Case"
|
||||
physical_exam_finish_button.custom_minimum_size = Vector2(170.0, 48.0)
|
||||
physical_exam_finish_button.pressed.connect(func(): physical_exam_finish_requested.emit())
|
||||
actions.add_child(physical_exam_finish_button)
|
||||
|
||||
|
||||
func _style_physical_exam_button(button: Button, complete: bool) -> void:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.88, 0.94, 0.97, 0.96) if complete else Color(1.0, 1.0, 1.0, 0.96)
|
||||
style.border_color = Color(0.19, 0.52, 0.43) if complete else Color(0.45, 0.54, 0.61)
|
||||
style.set_border_width_all(1)
|
||||
style.set_corner_radius_all(8)
|
||||
style.set_content_margin_all(12.0)
|
||||
var hover := style.duplicate() as StyleBoxFlat
|
||||
hover.border_color = Color(0.16, 0.45, 0.68)
|
||||
button.add_theme_stylebox_override("normal", style)
|
||||
button.add_theme_stylebox_override("disabled", style)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", hover)
|
||||
button.add_theme_font_size_override("font_size", 17)
|
||||
button.add_theme_color_override("font_color", Color(0.06, 0.12, 0.17))
|
||||
button.add_theme_color_override("font_disabled_color", Color(0.12, 0.31, 0.26) if complete else Color(0.42, 0.47, 0.51))
|
||||
|
||||
|
||||
func _physical_exam_name(code: String) -> String:
|
||||
if physical_exam_buttons.has(code):
|
||||
return str(physical_exam_buttons[code].text).trim_prefix("[Done] ")
|
||||
return "examination"
|
||||
|
||||
|
||||
func _on_physical_exam_pressed(code: String) -> void:
|
||||
physical_exam_selected.emit(code)
|
||||
|
||||
|
||||
func show_debrief(debrief: Dictionary, score: Dictionary) -> void:
|
||||
if physical_exam_panel != null and is_instance_valid(physical_exam_panel):
|
||||
physical_exam_panel.visible = false
|
||||
computer_available = false
|
||||
close_computer()
|
||||
computer_launcher.visible = false
|
||||
hide_patient_dialogue()
|
||||
hide_dialogue_choices()
|
||||
if debrief_panel != null and is_instance_valid(debrief_panel):
|
||||
debrief_panel.queue_free()
|
||||
debrief_panel = PanelContainer.new()
|
||||
debrief_panel.name = "InterviewDebrief"
|
||||
debrief_panel.z_index = 50
|
||||
debrief_panel.size = Vector2(870.0, 600.0)
|
||||
debrief_panel.position = (size - debrief_panel.size) * 0.5
|
||||
var panel_style := StyleBoxFlat.new()
|
||||
panel_style.bg_color = Color(0.96, 0.95, 0.91, 0.97)
|
||||
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.8)
|
||||
panel_style.set_border_width_all(2)
|
||||
panel_style.set_corner_radius_all(12)
|
||||
panel_style.set_content_margin_all(28.0)
|
||||
debrief_panel.add_theme_stylebox_override("panel", panel_style)
|
||||
add_child(debrief_panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 16)
|
||||
debrief_panel.add_child(content)
|
||||
var heading := Label.new()
|
||||
heading.text = "Interview Debrief"
|
||||
heading.add_theme_font_size_override("font_size", 30)
|
||||
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
|
||||
content.add_child(heading)
|
||||
var diagnosis := Label.new()
|
||||
diagnosis.text = str(debrief.get("likelyDiagnosis", "Interview complete"))
|
||||
diagnosis.add_theme_font_size_override("font_size", 23)
|
||||
diagnosis.add_theme_color_override("font_color", Color(0.12, 0.36, 0.27))
|
||||
diagnosis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content.add_child(diagnosis)
|
||||
var summary := Label.new()
|
||||
summary.text = str(debrief.get("summary", ""))
|
||||
summary.add_theme_font_size_override("font_size", 18)
|
||||
summary.add_theme_color_override("font_color", Color(0.08, 0.12, 0.15))
|
||||
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content.add_child(summary)
|
||||
var score_label := Label.new()
|
||||
score_label.text = "History coverage: %d%%" % int(round(float(score.get("percent", 0.0))))
|
||||
score_label.add_theme_font_size_override("font_size", 20)
|
||||
content.add_child(score_label)
|
||||
var covered: Array[String] = []
|
||||
var missed: Array[String] = []
|
||||
for raw_criterion in score.get("criteria", []):
|
||||
var criterion: Dictionary = raw_criterion
|
||||
var title := str(criterion.get("title", "History topic"))
|
||||
if str(criterion.get("status", "")) == "credited":
|
||||
covered.append(title)
|
||||
elif str(criterion.get("status", "")) == "missed":
|
||||
missed.append(title)
|
||||
var coverage := Label.new()
|
||||
coverage.text = "Covered: %s\nMissed optional topics: %s" % [
|
||||
", ".join(covered), "None" if missed.is_empty() else ", ".join(missed)]
|
||||
coverage.add_theme_font_size_override("font_size", 16)
|
||||
coverage.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
content.add_child(coverage)
|
||||
var points := Label.new()
|
||||
var teaching_points: Array = debrief.get("teachingPoints", [])
|
||||
points.text = "Key points\n- " + "\n- ".join(teaching_points) if not teaching_points.is_empty() else ""
|
||||
points.add_theme_font_size_override("font_size", 17)
|
||||
points.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
points.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content.add_child(points)
|
||||
var actions := HBoxContainer.new()
|
||||
actions.alignment = BoxContainer.ALIGNMENT_END
|
||||
actions.add_theme_constant_override("separation", 12)
|
||||
content.add_child(actions)
|
||||
var return_button := Button.new()
|
||||
return_button.text = "Return to Cases"
|
||||
return_button.custom_minimum_size = Vector2(190.0, 48.0)
|
||||
return_button.pressed.connect(func(): debrief_return_requested.emit())
|
||||
actions.add_child(return_button)
|
||||
var repeat_button := Button.new()
|
||||
repeat_button.text = "Repeat Interview"
|
||||
repeat_button.custom_minimum_size = Vector2(190.0, 48.0)
|
||||
repeat_button.pressed.connect(func(): debrief_repeat_requested.emit())
|
||||
actions.add_child(repeat_button)
|
||||
|
||||
@@ -3,7 +3,9 @@ extends Control
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
|
||||
const DESIGN_SIZE := Vector2(1500.0, 800.0)
|
||||
const DESIGN_SIZE := Vector2(1600.0, 900.0)
|
||||
const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001"
|
||||
const FOCUSED_INTERVIEW_REVISION := 2
|
||||
|
||||
var main
|
||||
var stage_root: Control
|
||||
@@ -16,6 +18,8 @@ var current_view := ""
|
||||
var cases_loaded := false
|
||||
var active_loaded := false
|
||||
var pending_action := ""
|
||||
var pending_exam_code := ""
|
||||
var restart_case_external_id := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -39,10 +43,13 @@ func _connect_service() -> void:
|
||||
CCSService.attempt_changed.connect(_on_attempt_changed)
|
||||
if not CCSService.request_failed.is_connected(_on_request_failed):
|
||||
CCSService.request_failed.connect(_on_request_failed)
|
||||
if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned):
|
||||
CCSService.attempt_abandoned.connect(_on_attempt_abandoned)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CCSService.launch_origin = "ccs_cases_2"
|
||||
restart_case_external_id = ""
|
||||
cases_loaded = false
|
||||
active_loaded = false
|
||||
pending_action = "loading"
|
||||
@@ -52,12 +59,27 @@ func on_show(_payload: Variant = null) -> void:
|
||||
|
||||
|
||||
func on_hide() -> void:
|
||||
restart_case_external_id = ""
|
||||
_clear_active_scene()
|
||||
|
||||
|
||||
func set_pause_menu_open(open: bool) -> void:
|
||||
if active_scene != null and active_scene.has_method("set_dialogue_input_enabled"):
|
||||
active_scene.set_dialogue_input_enabled(not open)
|
||||
|
||||
|
||||
func close_transient_overlay() -> bool:
|
||||
if active_scene != null and active_scene.has_method("close_transient_overlay"):
|
||||
return bool(active_scene.close_transient_overlay())
|
||||
return false
|
||||
|
||||
|
||||
func compatible_cases(cases: Array) -> Array:
|
||||
return cases.filter(func(item: Variant) -> bool:
|
||||
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
|
||||
return typeof(item) == TYPE_DICTIONARY \
|
||||
and bool(item.get("interactiveInterviewAvailable", false)) \
|
||||
and bool(item.get("graphicalPresentationAvailable", false)) \
|
||||
and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office")
|
||||
|
||||
|
||||
func _on_cases_changed() -> void:
|
||||
@@ -68,6 +90,7 @@ func _on_cases_changed() -> void:
|
||||
func _on_attempt_changed() -> void:
|
||||
active_loaded = true
|
||||
pending_action = ""
|
||||
pending_exam_code = ""
|
||||
_render_state()
|
||||
|
||||
|
||||
@@ -77,6 +100,10 @@ func _render_state() -> void:
|
||||
if not active_loaded:
|
||||
return
|
||||
var attempt: Dictionary = CCSService.attempt
|
||||
if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \
|
||||
and str(attempt.get("experienceMode", "")) == "graphical_interview":
|
||||
_render_debrief(attempt)
|
||||
return
|
||||
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
|
||||
_render_attempt(attempt)
|
||||
elif cases_loaded and active_loaded:
|
||||
@@ -85,20 +112,27 @@ func _render_state() -> void:
|
||||
|
||||
func _render_attempt(attempt: Dictionary) -> void:
|
||||
if str(attempt.get("state", "")) != "active":
|
||||
_handoff_to_classic_player()
|
||||
_show_unsupported_attempt("This active case is not available in the graphical interview yet.")
|
||||
return
|
||||
var interview: Dictionary = attempt.get("interview", {})
|
||||
if interview.is_empty():
|
||||
_handoff_to_classic_player()
|
||||
_show_unsupported_attempt("This active case does not include a graphical dialogue interview.")
|
||||
return
|
||||
var node: Dictionary = interview.get("currentNode", {})
|
||||
if node.is_empty():
|
||||
_handoff_to_classic_player()
|
||||
_show_unsupported_attempt("The graphical interview could not load its current dialogue.")
|
||||
return
|
||||
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||
_handoff_to_classic_player()
|
||||
if _is_retired_interview_revision(attempt, node):
|
||||
_restart_with_current_interview(attempt)
|
||||
return
|
||||
_show_doctor_office(node)
|
||||
if str(attempt.get("experienceMode", "management")) != "graphical_interview" \
|
||||
and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||
_show_unsupported_attempt("This case has progressed beyond the graphical interview.")
|
||||
return
|
||||
if current_view == "physical_examination" and bool(node.get("isTerminal", false)):
|
||||
_show_physical_examination(attempt)
|
||||
return
|
||||
_show_doctor_office(node, attempt)
|
||||
|
||||
|
||||
func _build_selection_view() -> void:
|
||||
@@ -113,7 +147,7 @@ func _build_selection_view() -> void:
|
||||
heading.add_theme_font_size_override("font_size", 28)
|
||||
selection_view.add_child(heading)
|
||||
var description := Label.new()
|
||||
description.text = "Begin a timed patient interview, then continue into clinical management."
|
||||
description.text = "Interview the patient by selecting the questions you want to ask."
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Brand.apply_font(description)
|
||||
description.add_theme_font_size_override("font_size", 16)
|
||||
@@ -151,12 +185,18 @@ func _show_selection(message: String = "") -> void:
|
||||
retry_button.visible = false
|
||||
|
||||
|
||||
func _show_unsupported_attempt(message: String) -> void:
|
||||
_show_selection(message)
|
||||
_clear_children(case_list)
|
||||
status_label.text += " Finish or abandon it from CCS Cases before starting another graphical interview."
|
||||
|
||||
|
||||
func _render_case_selection() -> void:
|
||||
_show_selection()
|
||||
_clear_children(case_list)
|
||||
var available := compatible_cases(CCSService.cases)
|
||||
if available.is_empty():
|
||||
status_label.text = "No published CCS cases currently include an interactive interview."
|
||||
status_label.text = "No published CCS cases currently have a supported graphical interview."
|
||||
return
|
||||
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
|
||||
retry_button.visible = false
|
||||
@@ -183,13 +223,24 @@ func _start_case(case_external_id: String) -> void:
|
||||
CCSService.create_attempt(case_external_id, "timed")
|
||||
|
||||
|
||||
func _show_doctor_office(node: Dictionary) -> void:
|
||||
func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
|
||||
if active_scene == null:
|
||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
||||
active_scene.choice_selected.connect(_submit_dialogue_choice)
|
||||
active_scene.interview_continue_requested.connect(_handoff_to_classic_player)
|
||||
active_scene.interview_continue_requested.connect(_begin_physical_examination)
|
||||
active_scene.physical_exam_selected.connect(_submit_physical_exam)
|
||||
active_scene.physical_exam_finish_requested.connect(_finish_physical_examination)
|
||||
active_scene.debrief_return_requested.connect(_return_to_cases)
|
||||
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
||||
selection_view.visible = false
|
||||
stage_root.visible = true
|
||||
active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management")))
|
||||
active_scene.configure_presentation(
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")))
|
||||
active_scene.configure_interview(node)
|
||||
active_scene.set_choices_pending(not pending_action.is_empty())
|
||||
|
||||
@@ -202,10 +253,105 @@ func _submit_dialogue_choice(choice_id: String) -> void:
|
||||
CCSService.send_dialogue_choice(choice_id)
|
||||
|
||||
|
||||
func _handoff_to_classic_player() -> void:
|
||||
current_view = "handoff"
|
||||
if main != null:
|
||||
main.show_screen("ccs_player")
|
||||
func _begin_physical_examination() -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
_show_physical_examination(CCSService.attempt)
|
||||
|
||||
|
||||
func _show_physical_examination(attempt: Dictionary) -> void:
|
||||
if active_scene == null:
|
||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "physical_examination")
|
||||
active_scene.physical_exam_selected.connect(_submit_physical_exam)
|
||||
active_scene.physical_exam_finish_requested.connect(_finish_physical_examination)
|
||||
active_scene.debrief_return_requested.connect(_return_to_cases)
|
||||
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
||||
selection_view.visible = false
|
||||
stage_root.visible = true
|
||||
active_scene.configure_experience_mode("graphical_interview")
|
||||
active_scene.configure_presentation(
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")))
|
||||
active_scene.show_physical_exam(
|
||||
attempt.get("availableExams", []), attempt.get("chart", []), pending_exam_code)
|
||||
current_view = "physical_examination"
|
||||
|
||||
|
||||
func _submit_physical_exam(exam_code: String) -> void:
|
||||
if not pending_action.is_empty() or exam_code.is_empty():
|
||||
return
|
||||
pending_action = "physical_exam"
|
||||
pending_exam_code = exam_code
|
||||
active_scene.set_physical_exam_pending(exam_code)
|
||||
CCSService.send_command("exam", exam_code)
|
||||
|
||||
|
||||
func _finish_physical_examination() -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "finishing_exam"
|
||||
active_scene.set_physical_exam_pending("finishing_case")
|
||||
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", "")))
|
||||
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 interview to its latest patient history dialogue...")
|
||||
CCSService.abandon()
|
||||
|
||||
|
||||
func _on_attempt_abandoned() -> void:
|
||||
if restart_case_external_id.is_empty():
|
||||
return
|
||||
var case_external_id := restart_case_external_id
|
||||
restart_case_external_id = ""
|
||||
pending_action = "creating"
|
||||
status_label.text = "Starting the updated interview..."
|
||||
CCSService.create_attempt(case_external_id, "timed")
|
||||
|
||||
|
||||
func _on_request_failed(message: String) -> void:
|
||||
@@ -217,8 +363,17 @@ func _on_request_failed(message: String) -> void:
|
||||
status_label.text = "An active case was found. Loading it now..."
|
||||
CCSService.load_active()
|
||||
return
|
||||
if failed_action == "refreshing_interview":
|
||||
restart_case_external_id = ""
|
||||
status_label.text = "The updated interview could not be started. %s" % message
|
||||
retry_button.visible = true
|
||||
return
|
||||
if active_scene != null:
|
||||
active_scene.set_choices_pending(false)
|
||||
if current_view == "physical_examination":
|
||||
pending_exam_code = ""
|
||||
active_scene.show_physical_exam_error(message)
|
||||
else:
|
||||
active_scene.set_choices_pending(false)
|
||||
else:
|
||||
status_label.text = message
|
||||
retry_button.visible = failed_action == "loading"
|
||||
@@ -243,7 +398,7 @@ func _clear_active_scene() -> void:
|
||||
active_scene.get_parent().remove_child(active_scene)
|
||||
active_scene.queue_free()
|
||||
active_scene = null
|
||||
if current_view == "doctor_office":
|
||||
if current_view in ["doctor_office", "physical_examination"]:
|
||||
current_view = ""
|
||||
|
||||
|
||||
@@ -264,6 +419,6 @@ func _fit_stage() -> void:
|
||||
var available := size
|
||||
if available.x <= 0.0 or available.y <= 0.0:
|
||||
return
|
||||
var fit_scale := minf(available.x / DESIGN_SIZE.x, available.y / DESIGN_SIZE.y)
|
||||
stage_root.scale = Vector2.ONE * fit_scale
|
||||
stage_root.position = (available - DESIGN_SIZE * fit_scale) * 0.5
|
||||
stage_root.scale = Vector2.ONE
|
||||
stage_root.position = Vector2.ZERO
|
||||
stage_root.size = available
|
||||
|
||||
@@ -435,6 +435,7 @@ func on_show(payload: Variant = null) -> void:
|
||||
|
||||
func on_hide() -> void:
|
||||
timer.stop()
|
||||
opening_active = false
|
||||
_dismiss_dialogs()
|
||||
|
||||
|
||||
@@ -458,6 +459,12 @@ func _ccs_dialogs() -> Array[Window]:
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
# This screen stays subscribed to the shared CCS attempt while hidden.
|
||||
# Never allow its Window-based dialogs to appear over CCS Cases 2.
|
||||
if not visible:
|
||||
opening_active = false
|
||||
_dismiss_dialogs()
|
||||
return
|
||||
var attempt: Dictionary = CCSService.attempt
|
||||
if attempt.is_empty():
|
||||
return
|
||||
|
||||
+32
-6
@@ -126,11 +126,11 @@ func _apply_screen_margins(screen_name: String) -> void:
|
||||
if screens_margin == null or screen_name.is_empty():
|
||||
return
|
||||
var is_fullscreen_study := screen_name == "player"
|
||||
var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44))
|
||||
var margin := 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 44))
|
||||
screens_margin.add_theme_constant_override("margin_left", margin)
|
||||
screens_margin.add_theme_constant_override("margin_right", margin)
|
||||
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
|
||||
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else (12 if is_fullscreen_study else 28))
|
||||
screens_margin.add_theme_constant_override("margin_top", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else (20 if compact_shell else 34)))
|
||||
screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name in ["login", "ccs_cases_2"] else (12 if is_fullscreen_study else 28))
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
@@ -582,7 +582,7 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
|
||||
|
||||
|
||||
func _screen_uses_shell(screen_name: String) -> bool:
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player", "ccs_cases_2"]
|
||||
|
||||
|
||||
func _update_shell_visibility(screen_name: String) -> void:
|
||||
@@ -699,15 +699,29 @@ func _input(event: InputEvent) -> void:
|
||||
if not AuthState.is_logged_in or current_screen_name == "login":
|
||||
return
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
if _close_current_transient_overlay():
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if drawer_open:
|
||||
_close_drawer()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
|
||||
if _screen_uses_pause_menu(current_screen_name):
|
||||
_toggle_app_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _close_current_transient_overlay() -> bool:
|
||||
var screen: Control = screens.get(current_screen_name)
|
||||
if screen != null and screen.has_method("close_transient_overlay"):
|
||||
return bool(screen.close_transient_overlay())
|
||||
return false
|
||||
|
||||
|
||||
func _screen_uses_pause_menu(screen_name: String) -> bool:
|
||||
return screen_name in ["player", "ccs_player", "ccs_cases_2", "flashcards_study"]
|
||||
|
||||
|
||||
func _refresh_header_controls() -> void:
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
@@ -875,13 +889,15 @@ func _toggle_app_menu() -> void:
|
||||
_close_app_menu()
|
||||
else:
|
||||
UISoundService.play_sound("open")
|
||||
_set_current_screen_pause_menu_open(true)
|
||||
app_menu.visible = true
|
||||
app_menu.size = app_menu.custom_minimum_size
|
||||
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
|
||||
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
|
||||
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "ccs_cases_2", "flashcards_study"]
|
||||
if menu_exit_button.visible:
|
||||
menu_exit_button.text = (
|
||||
"Abandon CCS Case" if current_screen_name == "ccs_player"
|
||||
else "Exit CCS Interview" if current_screen_name == "ccs_cases_2"
|
||||
else "Exit Study Session" if current_screen_name == "flashcards_study"
|
||||
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
|
||||
else "Exit Run")
|
||||
@@ -892,9 +908,16 @@ func _toggle_app_menu() -> void:
|
||||
func _close_app_menu() -> void:
|
||||
if app_menu != null:
|
||||
app_menu.visible = false
|
||||
_set_current_screen_pause_menu_open(false)
|
||||
BlockSessionService.resume_current_question_timer()
|
||||
|
||||
|
||||
func _set_current_screen_pause_menu_open(open: bool) -> void:
|
||||
var screen = screens.get(current_screen_name)
|
||||
if screen != null and screen.has_method("set_pause_menu_open"):
|
||||
screen.set_pause_menu_open(open)
|
||||
|
||||
|
||||
func _navigate_from_pause(target_screen: String) -> void:
|
||||
var return_screen := current_screen_name
|
||||
show_screen(target_screen, {"return_screen": return_screen})
|
||||
@@ -911,6 +934,9 @@ func exit_current_run() -> void:
|
||||
if current_screen_name == "flashcards_study":
|
||||
show_screen("flashcards_hub")
|
||||
return
|
||||
if current_screen_name == "ccs_cases_2":
|
||||
show_screen("dashboard")
|
||||
return
|
||||
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||
BlockSessionService.finish_block()
|
||||
elif BlockSessionService.session_kind == "simulation":
|
||||
|
||||
@@ -23,6 +23,16 @@ func _run() -> void:
|
||||
var nav_button := main_screen.sidebar.find_child("NavCcsCases2", true, false) as Button
|
||||
_assert(nav_button != null and nav_button.visible, "UAT should expose a CCS Cases 2 navigation item")
|
||||
_assert(main_screen.screens.has("ccs_cases_2"), "UAT should register the graphical CCS screen")
|
||||
_assert(not main_screen._screen_uses_shell("ccs_cases_2"), "CCS Cases 2 should hide the application shell")
|
||||
main_screen._apply_screen_margins("ccs_cases_2")
|
||||
_assert(main_screen.screens_margin.get_theme_constant("margin_left") == 0, "fullscreen CCS should remove horizontal screen margins")
|
||||
_assert(main_screen.screens_margin.get_theme_constant("margin_top") == 0, "fullscreen CCS should remove vertical screen margins")
|
||||
main_screen.current_screen_name = "ccs_cases_2"
|
||||
_assert(main_screen._screen_uses_pause_menu("ccs_cases_2"), "CCS Cases 2 should route Escape to the pause menu")
|
||||
main_screen._toggle_app_menu()
|
||||
_assert(main_screen.app_menu.visible, "the pause menu should open over CCS Cases 2")
|
||||
_assert(main_screen.menu_exit_button.visible and main_screen.menu_exit_button.text == "Exit CCS Interview", "the CCS pause menu should expose a clear exit action")
|
||||
main_screen._close_app_menu()
|
||||
main_screen.queue_free()
|
||||
await process_frame
|
||||
ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
|
||||
@@ -40,7 +50,11 @@ func _run() -> void:
|
||||
await process_frame
|
||||
|
||||
service.cases = [
|
||||
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
|
||||
{
|
||||
"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2,
|
||||
"realTimeMinutes": 20, "interactiveInterviewAvailable": true, "graphicalPresentationAvailable": true,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}
|
||||
},
|
||||
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
|
||||
]
|
||||
service.attempt = {}
|
||||
@@ -50,22 +64,207 @@ func _run() -> void:
|
||||
await process_frame
|
||||
_assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection")
|
||||
_assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection")
|
||||
var retired_attempt := {
|
||||
"caseExternalId": "ccs-gastro-duodenal-ulcer-office-001",
|
||||
"graphicalPresentation": {"interviewRevision": 1}
|
||||
}
|
||||
_assert(screen._is_retired_interview_revision(retired_attempt, {
|
||||
"id": "fatigue_course", "patientText": "I feel tired.", "choices": []
|
||||
}), "an older authored interview revision should trigger a restart")
|
||||
retired_attempt.graphicalPresentation.interviewRevision = 2
|
||||
_assert(not screen._is_retired_interview_revision(retired_attempt, {}),
|
||||
"the current authored interview revision should remain active")
|
||||
|
||||
service.attempt = _sample_attempt(false)
|
||||
screen._render_state()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office")
|
||||
_assert(screen.active_scene.dialogue_text.text == "Hello, doctor.", "the patient bubble should use the server-provided current node")
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "the opening should wait for a clinician dialogue selection")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices")
|
||||
_assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio")
|
||||
_assert(screen.active_scene.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.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.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 <= 310.0, "Medical History should size closely to its short 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.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.choice_container.visible, "the next choices should wait until the patient finishes speaking")
|
||||
_assert(screen.active_scene.dialogue_pages.size() > 1, "long patient responses should split into readable dialogue pages")
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "the first patient response page should fit inside its speech bubble")
|
||||
_assert(is_equal_approx(screen.active_scene.dialogue_text.offset_left, 96.0), "patient dialogue should retain the increased left inset")
|
||||
_assert(_bubble_matches_content(screen.active_scene), "the speech bubble should size itself to the wrapped response page")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.visible, "the continuation prompt should remain visible while text is typing")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.text == "Click to continue", "every dialogue page should use supported continuation wording")
|
||||
_assert(screen.active_scene.dialogue_text.visible_characters_behavior == TextServer.VC_CHARS_AFTER_SHAPING, "typewriter text should wrap before glyphs are revealed")
|
||||
var wrapped_line_count: int = screen.active_scene.dialogue_text.get_line_count()
|
||||
screen.active_scene.open_computer()
|
||||
var computer_blocked_page: int = screen.active_scene.dialogue_page_index
|
||||
var computer_click := _left_click(screen.active_scene.get_global_rect().get_center())
|
||||
screen.active_scene._input(computer_click)
|
||||
_assert(screen.active_scene.dialogue_page_index == computer_blocked_page and not screen.active_scene.dialogue_reveal_complete, "the open computer should block scene-wide dialogue clicks")
|
||||
screen.active_scene.computer_close_button.pressed.emit()
|
||||
_assert(not screen.active_scene.is_computer_open(), "the computer close button should return to the interview")
|
||||
var outside_click := _left_click(screen.active_scene.get_global_rect().end + Vector2(50.0, 50.0))
|
||||
screen.active_scene._input(outside_click)
|
||||
_assert(not screen.active_scene.dialogue_reveal_complete, "clicks outside the doctor scene should not affect dialogue")
|
||||
var scene_click := _left_click(screen.active_scene.get_global_rect().get_center())
|
||||
screen.set_pause_menu_open(true)
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(not screen.active_scene.dialogue_reveal_complete, "dialogue should ignore scene clicks while the Escape menu is open")
|
||||
screen.set_pause_menu_open(false)
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page")
|
||||
_assert(screen.active_scene.dialogue_text.get_line_count() == wrapped_line_count, "revealing typewriter text should not shift words between lines")
|
||||
_assert(screen.active_scene.dialogue_continue_hint.visible, "completed dialogue pages should invite the learner to continue")
|
||||
await create_timer(1.35).timeout
|
||||
_assert(screen.active_scene.is_patient_dialogue_visible(), "patient dialogue should remain until the learner advances it")
|
||||
_assert(not screen.active_scene.choice_container.visible, "questions must not appear on a timer")
|
||||
while screen.active_scene.dialogue_page_index < screen.active_scene.dialogue_pages.size() - 1:
|
||||
screen.active_scene._input(scene_click)
|
||||
await process_frame
|
||||
_assert(_label_content_fits(screen.active_scene.dialogue_text), "every patient response page should fit inside its speech bubble")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene._input(scene_click)
|
||||
await process_frame
|
||||
_assert(not screen.active_scene.is_patient_dialogue_visible(), "questions should replace patient dialogue after the final learner click")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the first question page should contain four choices")
|
||||
screen.active_scene._input(scene_click)
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "scene clicks should not affect explicit question controls")
|
||||
_assert(screen.active_scene.choice_pager.visible, "eight choices should display paging controls")
|
||||
_assert(screen.active_scene.choice_page == 0, "new question sets should begin on the first page")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_0", "topic_1", "topic_2", "topic_3"], "the first page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "first-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(1)
|
||||
await process_frame
|
||||
_assert(screen.active_scene.choice_page == 1, "Next should advance to the second question page")
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 4, "the second question page should contain the remaining choices")
|
||||
_assert(_visible_choice_ids(screen.active_scene) == ["topic_4", "topic_5", "topic_6", "topic_7"], "the second page should preserve choice order and IDs")
|
||||
_assert(_choice_labels_fit(screen.active_scene), "second-page question text should fit within every button")
|
||||
screen.active_scene._change_choice_page(-1)
|
||||
_assert(screen.active_scene.choice_page == 0, "Previous should return to the first question page")
|
||||
|
||||
service.attempt = _sample_attempt(true)
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
|
||||
_assert(not screen.active_scene.choice_container.visible, "the terminal action should wait for the closing response")
|
||||
screen.active_scene.complete_dialogue_reveal()
|
||||
screen.active_scene.advance_dialogue()
|
||||
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
|
||||
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
|
||||
var terminal_choice := screen.active_scene.choice_container.get_child(0) as Control
|
||||
_assert(terminal_choice.size.x <= 360.0, "a single terminal choice should remain a compact centered card")
|
||||
_assert((terminal_choice.get_child(0) as Label).text == "Begin Physical Examination",
|
||||
"terminal dialogue should lead into the graphical physical examination")
|
||||
screen.active_scene.interview_continue_requested.emit()
|
||||
_assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player")
|
||||
await process_frame
|
||||
_assert(screen.current_view == "physical_examination", "the examination should remain inside CCS Cases 2")
|
||||
_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")
|
||||
_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 == "physical_examination", "attempt refreshes should preserve the physical examination phase")
|
||||
_assert(screen.active_scene.physical_exam_buttons.abdomen.text.begins_with("[Done]"), "completed exams should be marked")
|
||||
_assert(screen.active_scene.physical_exam_buttons.abdomen.disabled, "completed exams should not be repeated")
|
||||
_assert(screen.active_scene.physical_exam_finding_text.text == "Mild epigastric tenderness.", "the returned chart finding should be displayed")
|
||||
|
||||
service.attempt = {"id": "classic-attempt", "state": "active", "openingCompleted": false, "interview": {}}
|
||||
host.shown_screen = ""
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "case_selection", "unsupported active attempts should remain outside the classic player")
|
||||
_assert(host.shown_screen.is_empty(), "CCS Cases 2 should not trigger classic opening overlays")
|
||||
|
||||
service.attempt = _sample_graphical_debrief()
|
||||
screen._on_attempt_changed()
|
||||
await process_frame
|
||||
_assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2")
|
||||
_assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief")
|
||||
_assert(not screen.active_scene.computer_launcher.visible, "the computer launcher should stay hidden during the interview debrief")
|
||||
|
||||
var sizing_scene = load("res://scenes/ccs_cases_2/doctor_office.tscn").instantiate()
|
||||
host.add_child(sizing_scene)
|
||||
await process_frame
|
||||
sizing_scene.show_patient_dialogue("Short response.")
|
||||
await process_frame
|
||||
var short_height: float = sizing_scene.dialogue_bubble.size.y
|
||||
_assert(is_equal_approx(short_height, sizing_scene.DIALOGUE_BUBBLE_MIN_HEIGHT), "short dialogue should use the minimum bubble height")
|
||||
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should fit inside a minimum-height bubble")
|
||||
_assert(sizing_scene.dialogue_continue_hint.position.y - sizing_scene.dialogue_text.get_rect().end.y >= 8.0, "the continuation hint should have visible padding above it")
|
||||
sizing_scene.show_patient_dialogue("Line one explains the symptom.\nLine two adds timing.\nLine three adds context.\nLine four concludes.")
|
||||
await process_frame
|
||||
_assert(sizing_scene.dialogue_bubble.size.y > short_height, "multi-line dialogue should increase the bubble height")
|
||||
_assert(sizing_scene.dialogue_bubble.size.y <= sizing_scene.DIALOGUE_BUBBLE_MAX_HEIGHT, "dialogue bubble growth should remain bounded")
|
||||
_assert(_bubble_matches_content(sizing_scene), "the expanded bubble should contain all wrapped text")
|
||||
sizing_scene.complete_dialogue_reveal()
|
||||
_assert(sizing_scene.dialogue_bubble.get_global_rect().encloses(sizing_scene.dialogue_continue_hint.get_global_rect()), "the continuation hint should remain inside the resized bubble")
|
||||
sizing_scene.queue_free()
|
||||
|
||||
screen.on_hide()
|
||||
await process_frame
|
||||
@@ -83,15 +282,143 @@ func _sample_attempt(completed: bool) -> Dictionary:
|
||||
"id": "done" if completed else "greeting",
|
||||
"patientText": "Okay, doctor." if completed else "Hello, doctor.",
|
||||
"isTerminal": completed,
|
||||
"suppressPatientText": not completed,
|
||||
"choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}]
|
||||
}
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 1,
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 2},
|
||||
"medicalHistory": "Hyperlipidemia",
|
||||
"vitals": {
|
||||
"temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",
|
||||
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
|
||||
},
|
||||
"openingCompleted": completed,
|
||||
"experienceMode": "graphical_interview",
|
||||
"availableExams": _sample_exams(), "chart": [],
|
||||
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
||||
}
|
||||
|
||||
|
||||
func _sample_exams() -> Array:
|
||||
var definitions := [
|
||||
["physical_appearance", "Physical Appearance"], ["skin", "Skin"], ["breasts", "Breasts"],
|
||||
["lymph_nodes", "Lymph Nodes"], ["heent_neck", "HEENT / Neck"], ["chest_lungs", "Chest / Lungs"],
|
||||
["heart_cardiovascular", "Heart / Cardiovascular"], ["abdomen", "Abdomen"], ["genitalia", "Genitalia"],
|
||||
["rectal", "Rectal"], ["extremities_spine", "Extremities / Spine"], ["neuro_psych", "Neuro / Psych"]
|
||||
]
|
||||
var exams: Array = []
|
||||
for definition in definitions:
|
||||
exams.append({"code": definition[0], "name": definition[1], "durationMinutes": 1})
|
||||
return exams
|
||||
|
||||
|
||||
func _sample_graphical_debrief() -> Dictionary:
|
||||
return {
|
||||
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
|
||||
"experienceMode": "graphical_interview", "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
|
||||
"interviewDebrief": {
|
||||
"likelyDiagnosis": "Probable duodenal ulcer", "summary": "A focused history supports the assessment.",
|
||||
"teachingPoints": ["Characterize dizziness.", "Screen for bleeding."]
|
||||
},
|
||||
"score": {"percent": 85.0}
|
||||
}
|
||||
|
||||
|
||||
func _sample_branching_attempt() -> Dictionary:
|
||||
var option_texts := [
|
||||
"Tell me more about the dizziness. Does the room spin, and have you fainted?",
|
||||
"How has the fatigue affected you, and how have you been sleeping and eating?",
|
||||
"You mentioned your stomach. Please describe the discomfort and when it occurs.",
|
||||
"Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?",
|
||||
"What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?",
|
||||
"Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?",
|
||||
"Do you smoke, drink alcohol, or use recreational drugs?",
|
||||
"Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?"
|
||||
]
|
||||
var choices: Array = []
|
||||
for index in 8:
|
||||
choices.append({"id": "topic_%d" % index, "text": option_texts[index], "nextNodeId": "topic_%d" % index})
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 2,
|
||||
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
|
||||
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
|
||||
"openingCompleted": false,
|
||||
"interview": {
|
||||
"currentNodeId": "chief_concern",
|
||||
"currentNode": {
|
||||
"id": "chief_concern",
|
||||
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
|
||||
"isTerminal": false,
|
||||
"choices": choices
|
||||
},
|
||||
"visitedChoiceIds": ["chief_concern"], "completed": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _visible_choice_ids(scene: Control) -> Array:
|
||||
var ids: Array = []
|
||||
for child in scene.choice_container.get_children():
|
||||
ids.append(str(child.get_meta("choice_id", "")))
|
||||
return ids
|
||||
|
||||
|
||||
func _vital_values(scene: Control) -> Array:
|
||||
var values: Array = []
|
||||
for index in range(1, scene.vitals_rows.get_child_count(), 2):
|
||||
values.append(scene.vitals_rows.get_child(index).text)
|
||||
return values
|
||||
|
||||
|
||||
func _vital_values_fit(scene: Control) -> bool:
|
||||
for index in range(1, scene.vitals_rows.get_child_count(), 2):
|
||||
var label := scene.vitals_rows.get_child(index) as Label
|
||||
if label == null or label.size.x < 220.0 or label.get_line_count() != 1:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _choice_labels_fit(scene: Control) -> bool:
|
||||
for button in scene.choice_container.get_children():
|
||||
var label := button.get_child(0) as Label
|
||||
if label == null or not _label_content_fits(label):
|
||||
return false
|
||||
if not scene.choice_area.get_global_rect().encloses(button.get_global_rect()):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _label_content_fits(label: Control) -> bool:
|
||||
if label is RichTextLabel:
|
||||
return label.get_content_height() <= label.size.y + 0.5
|
||||
var plain_label := label as Label
|
||||
if plain_label == null:
|
||||
return false
|
||||
var font := plain_label.get_theme_font("font")
|
||||
var font_size := plain_label.get_theme_font_size("font_size")
|
||||
var content_height := float(plain_label.get_line_count()) * font.get_height(font_size)
|
||||
return content_height <= plain_label.size.y + 0.5
|
||||
|
||||
|
||||
func _bubble_matches_content(scene: Control) -> bool:
|
||||
var required_height: float = scene.dialogue_text.get_content_height() + scene.DIALOGUE_BUBBLE_VERTICAL_CHROME
|
||||
var expected_height := clampf(
|
||||
required_height, scene.DIALOGUE_BUBBLE_MIN_HEIGHT, scene.DIALOGUE_BUBBLE_MAX_HEIGHT)
|
||||
return is_equal_approx(scene.dialogue_bubble.size.y, expected_height) \
|
||||
and _label_content_fits(scene.dialogue_text)
|
||||
|
||||
|
||||
func _left_click(position: Vector2) -> InputEventMouseButton:
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = MOUSE_BUTTON_LEFT
|
||||
event.pressed = true
|
||||
event.position = position
|
||||
return event
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
|
||||
@@ -66,7 +66,15 @@ func _run() -> void:
|
||||
_assert(player.time_dialog.visible, "CCS dialog lifecycle test should begin with an open popup")
|
||||
player.on_hide()
|
||||
_assert(not player.time_dialog.visible, "hiding the CCS screen should dismiss popup windows")
|
||||
player.visible = false
|
||||
ccs_service.attempt.openingCompleted = false
|
||||
player._render()
|
||||
await process_frame
|
||||
_assert(not player.intro_dialog.visible, "a hidden classic player must not open dialogs over CCS Cases 2")
|
||||
_assert(not player.opening_active, "a hidden classic player must not begin its opening sequence")
|
||||
|
||||
player.visible = true
|
||||
ccs_service.attempt.openingCompleted = true
|
||||
ccs_service.attempt.state = "final_orders"
|
||||
player._render()
|
||||
_assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
|
||||
|
||||
@@ -183,8 +183,10 @@ test("public API and disposable learner journey", async () => {
|
||||
const { data: cases } = await call("/api/ccs/cases", { token });
|
||||
expect(Array.isArray(cases)).toBeTruthy();
|
||||
expect(cases.length).toBeGreaterThan(0);
|
||||
const managementCase = cases.find((item) => item.experienceMode === "management");
|
||||
expect(managementCase, "UAT requires a published management CCS case for the smoke journey").toBeTruthy();
|
||||
const { data: attempt } = await call("/api/ccs/attempts", {
|
||||
method: "POST", token, body: { caseExternalId: "", mode: "timed" }
|
||||
method: "POST", token, body: { caseExternalId: managementCase.externalId, mode: "timed" }
|
||||
});
|
||||
activeCcsAttempt = attempt.id;
|
||||
expect(activeCcsAttempt).toBeTruthy();
|
||||
@@ -204,6 +206,8 @@ test("public API and disposable learner journey", async () => {
|
||||
}
|
||||
});
|
||||
expect(opened.openingCompleted).toBe(true);
|
||||
expect(Array.isArray(opened.availableExams)).toBeTruthy();
|
||||
expect(opened.availableExams.length, "management CCS smoke case must expose at least one exam").toBeGreaterThan(0);
|
||||
const exam = opened.availableExams[0];
|
||||
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
|
||||
method: "POST", token, body: {
|
||||
|
||||
Reference in New Issue
Block a user