extends SceneTree var failures := 0 class ScreenHost extends Control: var shown_screen := "" func show_screen(screen_name: String, _payload: Variant = null) -> void: shown_screen = screen_name func _initialize() -> void: call_deferred("_run") func _run() -> void: var prior_environment: String = ProjectSettings.get_setting("step3/deployment_environment", "development") ProjectSettings.set_setting("step3/deployment_environment", "uat") var main_screen = load("res://scripts/ui/main.gd").new() root.add_child(main_screen) await process_frame 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") main_screen.queue_free() await process_frame ProjectSettings.set_setting("step3/deployment_environment", prior_environment) var service = root.get_node("CCSService") var prior_attempt: Dictionary = service.attempt.duplicate(true) var prior_cases: Array = service.cases.duplicate(true) var prior_origin: String = service.launch_origin var host := ScreenHost.new() host.size = Vector2(1200.0, 700.0) root.add_child(host) var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new() screen.main = host host.add_child(screen) await process_frame service.cases = [ { "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 = {} screen.cases_loaded = true screen.active_loaded = true screen._render_state() 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") 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(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") 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") screen.active_scene.advance_dialogue() _assert(screen.active_scene.dialogue_page_index == 0, "clicking during typing should complete rather than skip the current page") _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.advance_dialogue() 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.advance_dialogue() 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") _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") 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") 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") screen.on_hide() await process_frame _assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene") host.queue_free() service.attempt = prior_attempt service.cases = prior_cases service.launch_origin = prior_origin await process_frame quit(1 if failures > 0 else 0) func _sample_attempt(completed: bool) -> Dictionary: var node := { "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"}, "openingCompleted": completed, "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} } 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 _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: Label) -> bool: var font := label.get_theme_font("font") var font_size := label.get_theme_font_size("font_size") var content_height := float(label.get_line_count()) * font.get_height(font_size) return content_height <= label.size.y + 0.5 func _assert(condition: bool, message: String) -> void: if condition: return failures += 1 push_error(message)