Add graphical CCS physical examination
This commit is contained in:
@@ -114,6 +114,16 @@ public sealed class CcsCaseTests
|
|||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal(2, request.Cases.Single().GraphicalPresentation.InterviewRevision);
|
Assert.Equal(2, request.Cases.Single().GraphicalPresentation.InterviewRevision);
|
||||||
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -110,7 +110,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowedLocations": ["office"],
|
"allowedLocations": ["office"],
|
||||||
"exams": [],
|
"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": [],
|
"orderResponses": [],
|
||||||
"events": [],
|
"events": [],
|
||||||
"scoringCriteria": [
|
"scoringCriteria": [
|
||||||
@@ -120,7 +133,14 @@
|
|||||||
{ "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": "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": "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": "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": "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": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ extends Control
|
|||||||
|
|
||||||
signal choice_selected(choice_id: String)
|
signal choice_selected(choice_id: String)
|
||||||
signal interview_continue_requested
|
signal interview_continue_requested
|
||||||
|
signal physical_exam_selected(exam_code: String)
|
||||||
|
signal physical_exam_finish_requested
|
||||||
signal debrief_return_requested
|
signal debrief_return_requested
|
||||||
signal debrief_repeat_requested
|
signal debrief_repeat_requested
|
||||||
|
|
||||||
@@ -48,6 +50,13 @@ var blink_tween: Tween
|
|||||||
var dialogue_tween: Tween
|
var dialogue_tween: Tween
|
||||||
var clock_seconds := 9.0 * 60.0 * 60.0
|
var clock_seconds := 9.0 * 60.0 * 60.0
|
||||||
var debrief_panel: PanelContainer
|
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 experience_mode := "management"
|
||||||
var pending_choices: Array = []
|
var pending_choices: Array = []
|
||||||
var available_choices: Array = []
|
var available_choices: Array = []
|
||||||
@@ -208,7 +217,7 @@ func configure_interview(node: Dictionary) -> void:
|
|||||||
if bool(node.get("isTerminal", false)):
|
if bool(node.get("isTerminal", false)):
|
||||||
next_choices = [{
|
next_choices = [{
|
||||||
"id": "__continue",
|
"id": "__continue",
|
||||||
"text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management"
|
"text": "Begin Physical Examination" if experience_mode == "graphical_interview" else "Continue to Patient Management"
|
||||||
}]
|
}]
|
||||||
else:
|
else:
|
||||||
next_choices = node.get("choices", [])
|
next_choices = node.get("choices", [])
|
||||||
@@ -719,7 +728,180 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void:
|
|||||||
choice_selected.emit(choice_id)
|
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:
|
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
|
computer_available = false
|
||||||
close_computer()
|
close_computer()
|
||||||
computer_launcher.visible = false
|
computer_launcher.visible = false
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var current_view := ""
|
|||||||
var cases_loaded := false
|
var cases_loaded := false
|
||||||
var active_loaded := false
|
var active_loaded := false
|
||||||
var pending_action := ""
|
var pending_action := ""
|
||||||
|
var pending_exam_code := ""
|
||||||
var restart_case_external_id := ""
|
var restart_case_external_id := ""
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ func _on_cases_changed() -> void:
|
|||||||
func _on_attempt_changed() -> void:
|
func _on_attempt_changed() -> void:
|
||||||
active_loaded = true
|
active_loaded = true
|
||||||
pending_action = ""
|
pending_action = ""
|
||||||
|
pending_exam_code = ""
|
||||||
_render_state()
|
_render_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +129,9 @@ func _render_attempt(attempt: Dictionary) -> void:
|
|||||||
and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
and bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||||
_show_unsupported_attempt("This case has progressed beyond the graphical interview.")
|
_show_unsupported_attempt("This case has progressed beyond the graphical interview.")
|
||||||
return
|
return
|
||||||
|
if current_view == "physical_examination" and bool(node.get("isTerminal", false)):
|
||||||
|
_show_physical_examination(attempt)
|
||||||
|
return
|
||||||
_show_doctor_office(node, attempt)
|
_show_doctor_office(node, attempt)
|
||||||
|
|
||||||
|
|
||||||
@@ -222,7 +227,9 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
|
|||||||
if active_scene == null:
|
if active_scene == null:
|
||||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
||||||
active_scene.choice_selected.connect(_submit_dialogue_choice)
|
active_scene.choice_selected.connect(_submit_dialogue_choice)
|
||||||
active_scene.interview_continue_requested.connect(_handoff_to_classic_player)
|
active_scene.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_return_requested.connect(_return_to_cases)
|
||||||
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
active_scene.debrief_repeat_requested.connect(_repeat_interview)
|
||||||
selection_view.visible = false
|
selection_view.visible = false
|
||||||
@@ -246,15 +253,48 @@ func _submit_dialogue_choice(choice_id: String) -> void:
|
|||||||
CCSService.send_dialogue_choice(choice_id)
|
CCSService.send_dialogue_choice(choice_id)
|
||||||
|
|
||||||
|
|
||||||
func _handoff_to_classic_player() -> void:
|
func _begin_physical_examination() -> void:
|
||||||
if str(CCSService.attempt.get("experienceMode", "")) == "graphical_interview":
|
if not pending_action.is_empty():
|
||||||
pending_action = "finishing_interview"
|
|
||||||
active_scene.set_choices_pending(true)
|
|
||||||
CCSService.send_command("exit_case")
|
|
||||||
return
|
return
|
||||||
current_view = "handoff"
|
_show_physical_examination(CCSService.attempt)
|
||||||
if main != null:
|
|
||||||
main.show_screen("ccs_player")
|
|
||||||
|
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:
|
func _render_debrief(attempt: Dictionary) -> void:
|
||||||
@@ -329,7 +369,11 @@ func _on_request_failed(message: String) -> void:
|
|||||||
retry_button.visible = true
|
retry_button.visible = true
|
||||||
return
|
return
|
||||||
if active_scene != null:
|
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:
|
else:
|
||||||
status_label.text = message
|
status_label.text = message
|
||||||
retry_button.visible = failed_action == "loading"
|
retry_button.visible = failed_action == "loading"
|
||||||
@@ -354,7 +398,7 @@ func _clear_active_scene() -> void:
|
|||||||
active_scene.get_parent().remove_child(active_scene)
|
active_scene.get_parent().remove_child(active_scene)
|
||||||
active_scene.queue_free()
|
active_scene.queue_free()
|
||||||
active_scene = null
|
active_scene = null
|
||||||
if current_view == "doctor_office":
|
if current_view in ["doctor_office", "physical_examination"]:
|
||||||
current_view = ""
|
current_view = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -211,8 +211,28 @@ func _run() -> void:
|
|||||||
_assert(not screen.active_scene.choice_pager.visible, "a single terminal action should not show paging controls")
|
_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
|
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.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()
|
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": {}}
|
service.attempt = {"id": "classic-attempt", "state": "active", "openingCompleted": false, "interview": {}}
|
||||||
host.shown_screen = ""
|
host.shown_screen = ""
|
||||||
@@ -275,10 +295,25 @@ func _sample_attempt(completed: bool) -> Dictionary:
|
|||||||
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
|
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
|
||||||
},
|
},
|
||||||
"openingCompleted": completed,
|
"openingCompleted": completed,
|
||||||
|
"experienceMode": "graphical_interview",
|
||||||
|
"availableExams": _sample_exams(), "chart": [],
|
||||||
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _sample_exams() -> Array:
|
||||||
|
var definitions := [
|
||||||
|
["physical_appearance", "Physical Appearance"], ["skin", "Skin"], ["breasts", "Breasts"],
|
||||||
|
["lymph_nodes", "Lymph Nodes"], ["heent_neck", "HEENT / Neck"], ["chest_lungs", "Chest / Lungs"],
|
||||||
|
["heart_cardiovascular", "Heart / Cardiovascular"], ["abdomen", "Abdomen"], ["genitalia", "Genitalia"],
|
||||||
|
["rectal", "Rectal"], ["extremities_spine", "Extremities / Spine"], ["neuro_psych", "Neuro / Psych"]
|
||||||
|
]
|
||||||
|
var exams: Array = []
|
||||||
|
for definition in definitions:
|
||||||
|
exams.append({"code": definition[0], "name": definition[1], "durationMinutes": 1})
|
||||||
|
return exams
|
||||||
|
|
||||||
|
|
||||||
func _sample_graphical_debrief() -> Dictionary:
|
func _sample_graphical_debrief() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
|
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
|
||||||
|
|||||||
Reference in New Issue
Block a user