diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index c75cefd..d43f8e2 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -101,8 +101,17 @@ public sealed class CcsCaseTests Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet())); Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode); var topics = request.Cases.Single().InitialInterview.Topics; - Assert.Equal(["chief_concern", "dizziness_character", "fatigue_course"], topics.Select(value => value.Id)); - Assert.Equal(2, topics.Count(value => value.Required)); + 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.Equal(2, request.Cases.Single().GraphicalPresentation.InterviewRevision); + Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single())); } [Fact] diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index b43702f..ed82c13 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -108,6 +108,7 @@ public class CcsGraphicalPresentation { public string SceneKey { get; set; } = ""; public string PatientAssetKey { get; set; } = ""; + public int InterviewRevision { get; set; } } public static class CcsExperienceModes @@ -379,6 +380,7 @@ public class CcsAttemptResponse public string Appearance { get; set; } = ""; public string History { get; set; } = ""; public List HistorySections { get; set; } = []; + public string MedicalHistory { get; set; } = ""; public Dictionary Vitals { get; set; } = []; public List Chart { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index e3c30d9..69f87cb 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -467,6 +467,7 @@ public sealed class CcsStore ? "" : 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 @@ -893,6 +894,10 @@ public sealed class CcsStore }); 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()); diff --git a/content/ccs/duodenal-ulcer-office-001.json b/content/ccs/duodenal-ulcer-office-001.json index 9379805..591eb5b 100644 --- a/content/ccs/duodenal-ulcer-office-001.json +++ b/content/ccs/duodenal-ulcer-office-001.json @@ -13,17 +13,20 @@ "experienceMode": "graphical_interview", "graphicalPresentation": { "sceneKey": "doctor_office", - "patientAssetKey": "patient_44_white_male" + "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 sleep-disrupting, gnawing epigastric discomfort that is worse on an empty stomach and temporarily relieved by food or antacids. He has no symptoms of gastrointestinal bleeding or other alarm features.", + "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": "Gnawing epigastric discomfort occurs when his stomach is empty and at night, and improves temporarily after food or antacids." }, - { "code": "alarm_features", "title": "Alarm features", "text": "No melena, hematemesis, persistent vomiting, dysphagia, unintentional weight loss, or syncope." } + { "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)", @@ -56,17 +59,53 @@ { "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, even on days when I sleep normally. I have not noticed fever, shortness of breath, chest pain, or a major change in my appetite.", + "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": "I have hyperlipidemia.", + "required": false, + "prerequisiteTopicIds": ["chief_concern"] } ], "debrief": { - "likelyDiagnosis": "Lightheadedness and fatigue requiring further evaluation", - "summary": "The focused opening interview identifies intermittent orthostatic lightheadedness without vertigo or syncope and several weeks of nonspecific fatigue. Additional clinical assessment would be needed before assigning a diagnosis.", + "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 the timing and functional impact of fatigue and ask about sleep, intake, and associated systemic 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." ] } }, @@ -77,7 +116,11 @@ "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": "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." } ], "references": [ { diff --git a/scenes/ccs_cases_2/doctor_office.tscn b/scenes/ccs_cases_2/doctor_office.tscn index 16b2271..5eebd02 100644 --- a/scenes/ccs_cases_2/doctor_office.tscn +++ b/scenes/ccs_cases_2/doctor_office.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=12 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"] @@ -10,6 +10,7 @@ [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/icons/lucide/heart-pulse.svg" id="10_vitals"] +[ext_resource type="Texture2D" path="res://assets/icons/lucide/clipboard-list.svg" id="11_history"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"] shader = ExtResource("5_patient_fit") @@ -287,6 +288,20 @@ expand_icon = true text = "Vitals" vertical_icon_alignment = 0 +[node name="MedicalHistoryLauncher" type="Button" parent="ComputerOverlay"] +layout_mode = 0 +offset_left = 166.0 +offset_top = 32.0 +offset_right = 342.0 +offset_bottom = 142.0 +mouse_default_cursor_shape = 2 +tooltip_text = "View medical history" +icon = ExtResource("11_history") +icon_max_width = 48 +expand_icon = true +text = "Medical History" +vertical_icon_alignment = 0 + [node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"] visible = false z_index = 2 @@ -353,6 +368,70 @@ 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 +size_flags_vertical = 3 +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." +autowrap_mode = 3 + [node name="Close" type="Button" parent="ComputerOverlay"] layout_mode = 1 anchors_preset = 1 diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index 8f47281..6d683b9 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -38,6 +38,11 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0 @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 @@ -54,8 +59,9 @@ var dialogue_reveal_complete := false var dialogue_layout_generation := 0 var dialogue_input_enabled := true var computer_available := true -var vitals_dragging := false -var vitals_drag_offset := Vector2.ZERO +var computer_drag_window: Control +var computer_drag_offset := Vector2.ZERO +var computer_window_z := 2 func _ready() -> void: @@ -65,10 +71,17 @@ func _ready() -> void: computer_close_button.pressed.connect(close_computer) vitals_launcher.pressed.connect(open_vitals) vitals_close_button.pressed.connect(close_vitals) - vitals_header.gui_input.connect(_on_vitals_header_gui_input) - resized.connect(_resize_vitals_window) + 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() - _resize_vitals_window() + _style_computer_window(medical_history_window) + _resize_computer_windows() _update_clock() _schedule_patient_blink() hide_patient_dialogue() @@ -93,7 +106,7 @@ func _update_clock() -> void: func configure_presentation( presentation: Dictionary, simulated_start: String, simulated_minute: int, - vitals: Dictionary = {}) -> void: + 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) @@ -102,6 +115,9 @@ func configure_presentation( 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: @@ -284,6 +300,7 @@ 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(): @@ -296,25 +313,28 @@ func is_computer_open() -> bool: func close_transient_overlay() -> bool: - if close_vitals(): - return true + 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 or vitals_window.visible: + if not computer_overlay.visible: return vitals_window.visible = true - vitals_launcher.visible = false + _bring_computer_window_to_front(vitals_window) vitals_close_button.grab_focus() func close_vitals() -> bool: if not vitals_window.visible: return false - vitals_dragging = false + if computer_drag_window == vitals_window: + computer_drag_window = null vitals_window.visible = false - vitals_launcher.visible = true return true @@ -322,6 +342,27 @@ 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) @@ -399,6 +440,10 @@ func _friendly_vital_name(key: String) -> String: 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) @@ -406,46 +451,83 @@ func _style_vitals_window() -> void: 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 - vitals_window.add_theme_stylebox_override("panel", panel_style) + window.add_theme_stylebox_override("panel", panel_style) -func _resize_vitals_window() -> void: - if vitals_window == null: +func _resize_computer_windows() -> void: + if vitals_window == null or medical_history_window == null: return - var window_size := Vector2( + var vitals_size := Vector2( minf(720.0, maxf(320.0, size.x - 48.0)), minf(540.0, maxf(360.0, size.y - 48.0))) - vitals_window.offset_left = -window_size.x * 0.5 - vitals_window.offset_top = -window_size.y * 0.5 - vitals_window.offset_right = window_size.x * 0.5 - vitals_window.offset_bottom = window_size.y * 0.5 + _set_centered_window_rect(vitals_window, vitals_size, Vector2(-55.0, -25.0)) + var history_size := Vector2( + minf(660.0, maxf(320.0, size.x - 48.0)), + minf(420.0, maxf(300.0, size.y - 48.0))) + _set_centered_window_rect(medical_history_window, history_size, Vector2(55.0, 25.0)) -func _on_vitals_header_gui_input(event: InputEvent) -> void: +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 vitals_close_button.get_global_rect().has_point(event.global_position): + if close_button.get_global_rect().has_point(event.global_position): return - vitals_dragging = true - vitals_drag_offset = event.global_position - vitals_window.global_position + computer_drag_window = window + computer_drag_offset = event.global_position - window.global_position + _bring_computer_window_to_front(window) else: - vitals_dragging = false - vitals_header.accept_event() - elif event is InputEventMouseMotion and vitals_dragging: - var target_global: Vector2 = event.global_position - vitals_drag_offset - _set_vitals_window_position(target_global - global_position) - vitals_header.accept_event() + 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 - vitals_window.size.x), - maxf(0.0, size.y - vitals_window.size.y)) - vitals_window.position = 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 diff --git a/scripts/ui/ccs_cases_2_screen.gd b/scripts/ui/ccs_cases_2_screen.gd index 0a5b495..9cc55bd 100644 --- a/scripts/ui/ccs_cases_2_screen.gd +++ b/scripts/ui/ccs_cases_2_screen.gd @@ -5,10 +5,7 @@ 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(1600.0, 900.0) const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001" -const RETIRED_INTERVIEW_TOPIC_IDS := [ - "abdominal_symptoms", "bleeding_alarm_features", "medications", - "medical_history", "social_history", "additional_review" -] +const FOCUSED_INTERVIEW_REVISION := 2 var main var stage_root: Control @@ -235,7 +232,8 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void: attempt.get("graphicalPresentation", {}), str(attempt.get("simulatedStartUtc", "")), int(attempt.get("simulatedMinute", 0)), - attempt.get("vitals", {})) + attempt.get("vitals", {}), + str(attempt.get("medicalHistory", ""))) active_scene.configure_interview(node) active_scene.set_choices_pending(not pending_action.is_empty()) @@ -270,7 +268,8 @@ func _render_debrief(attempt: Dictionary) -> void: attempt.get("graphicalPresentation", {}), str(attempt.get("simulatedStartUtc", "")), int(attempt.get("simulatedMinute", 0)), - attempt.get("vitals", {})) + attempt.get("vitals", {}), + str(attempt.get("medicalHistory", ""))) active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {})) current_view = "interview_debrief" @@ -289,16 +288,11 @@ func _repeat_interview() -> void: CCSService.create_attempt(case_external_id, "timed") -func _is_retired_interview_revision(attempt: Dictionary, node: Dictionary) -> bool: +func _is_retired_interview_revision(attempt: Dictionary, _node: Dictionary) -> bool: if str(attempt.get("caseExternalId", "")) != FOCUSED_INTERVIEW_CASE_ID: return false - if str(node.get("id", "")) in RETIRED_INTERVIEW_TOPIC_IDS: - return true - for raw_choice in node.get("choices", []): - if str(raw_choice.get("id", "")) in RETIRED_INTERVIEW_TOPIC_IDS: - return true - var patient_text := str(node.get("patientText", "")).to_lower() - return patient_text.contains("stomach bothers me") or patient_text.contains("gnawing ache") + var presentation: Dictionary = attempt.get("graphicalPresentation", {}) + return int(presentation.get("interviewRevision", 0)) < FOCUSED_INTERVIEW_REVISION func _restart_with_current_interview(attempt: Dictionary) -> void: @@ -306,7 +300,7 @@ func _restart_with_current_interview(attempt: Dictionary) -> void: return restart_case_external_id = str(attempt.get("caseExternalId", "")) pending_action = "refreshing_interview" - _show_selection("Updating this interview to its latest dizziness and fatigue dialogue...") + _show_selection("Updating this interview to its latest patient history dialogue...") CCSService.abandon() diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index 1e0512b..e2c9b08 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -65,19 +65,15 @@ func _run() -> void: _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" + "caseExternalId": "ccs-gastro-duodenal-ulcer-office-001", + "graphicalPresentation": {"interviewRevision": 1} } _assert(screen._is_retired_interview_revision(retired_attempt, { - "id": "fatigue_course", "patientText": "My stomach bothers me.", "choices": [] - }), "the retired stomach response should trigger a current-revision restart") - _assert(screen._is_retired_interview_revision(retired_attempt, { - "id": "chief_concern", "patientText": "Dizzy.", - "choices": [{"id": "abdominal_symptoms", "text": "Ask about stomach pain"}] - }), "retired stomach-topic choices should trigger a current-revision restart") - _assert(not screen._is_retired_interview_revision(retired_attempt, { - "id": "chief_concern", "patientText": "Dizzy and tired.", - "choices": [{"id": "dizziness_character", "text": "Ask about dizziness"}] - }), "the focused dizziness and fatigue revision should remain active") + "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() @@ -93,21 +89,41 @@ func _run() -> void: _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") 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.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 first Escape should close the Vitals app") + _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 second Escape should close the computer") + _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 third Escape should be available to open the pause menu") + _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") @@ -245,7 +261,8 @@ func _sample_attempt(completed: bool) -> Dictionary: 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"}, + "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"