Files
step3/tests/ccs_cases_2_ui_test.gd
T
2026-08-06 20:39:40 -05:00

279 lines
14 KiB
GDScript

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")
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(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")
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")
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")
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 _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)