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") _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) 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") var retired_attempt := { "caseExternalId": "ccs-gastro-duodenal-ulcer-office-001" } _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") 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(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_KEEP_ASPECT_CENTERED, "the computer should be fully contained without cropping") _assert(screen.close_transient_overlay(), "Escape routing should close the computer before the pause menu") _assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office") _assert(not screen.close_transient_overlay(), "a second Escape should be available to open the pause menu") _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") _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") 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") _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 _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: 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 failures += 1 push_error(message)