extends SceneTree const Brand := preload("res://scripts/ui/medmind_style.gd") var failures := 0 func _initialize() -> void: _test_reference_normalization() _test_reference_url_filter() _test_correct_feedback_does_not_auto_advance() _test_correct_review_locks_answer_interactions() _test_eliminated_choice_uses_rich_text_strikeout() _test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through() _test_explanation_highlighting_has_no_status_heading() _test_resolved_followup_stays_expanded_and_toggles() _test_review_content_normalization() _test_followup_attempt_order_is_stable() _test_review_followup_is_optional_and_single_attempt() _test_question_failure_retry_classification() _test_transient_question_failure_keeps_player_waiting() _test_feedback_context_excludes_study_content() _test_feedback_form_validation() _test_revisitable_navigation_prefers_unanswered_questions() _test_timer_text_avoids_redundant_frame_updates() _test_submit_styles_keep_stable_geometry() _test_core_timer_counts_up_and_remains_visible() await _test_player_runtime_construction() quit(1 if failures > 0 else 0) func _test_core_timer_counts_up_and_remains_visible() -> void: var player = load("res://scripts/ui/question_player.gd").new() var block_service = root.get_node("BlockSessionService") player.timer_bar = ProgressBar.new() player.timer_label = Label.new() player.timer_footer = HBoxContainer.new() player.timer_color_state = "" block_service.session_kind = "core" block_service.core_current_target_seconds = 100.0 block_service.current_question_started_at = 0.001 block_service.current_question_pause_started_at = 25.001 block_service.core_question_total_elapsed_seconds = 75.0 block_service.core_question_total_active_since = 0.0 player._set_answer_review_timer_visibility() _assert(player.timer_footer.visible and player.timer_bar.visible and player.timer_label.visible, "core timer should remain visible during correct-answer review") _assert(is_equal_approx(player.timer_bar.value, 75.0), "core timer bar should retain its original remaining-time progress") _assert(player.timer_label.text == "01:15", "core timer should display cumulative elapsed time") _assert(player.timer_color_state == "blue", "core timer should retain its original remaining-time warning behavior") block_service.core_question_total_elapsed_seconds = 110.0 block_service.current_question_pause_started_at = 110.001 player._update_timer_bar() _assert(is_equal_approx(player.timer_bar.value, 0.0) and player.timer_color_state == "red", "core timer bar should empty and turn red after exceeding its target") block_service.session_kind = "block" player._set_answer_review_timer_visibility() _assert(not player.timer_footer.visible, "non-core answer review should retain its existing hidden timer") player.timer_bar.free() player.timer_label.free() player.timer_footer.free() player.free() func _test_reference_normalization() -> void: var service = load("res://scripts/autoload/question_bank_service.gd").new() var references: Array = service._normalize_references([{ "Title": "Clinical guideline", "Publisher": "Example Society", "Url": "https://example.org/guideline", "Supports": "Recommended management", "AccessedOn": "2026-07-14", "Reviewer": "reviewer" }]) _assert(references.size() == 1, "a structured reference should be retained") _assert(references[0].get("title", "") == "Clinical guideline", "reference title should be normalized") _assert(references[0].get("accessed_on", "") == "2026-07-14", "reference access date should be normalized") service.free() func _test_reference_url_filter() -> void: var player = load("res://scripts/ui/question_player.gd").new() _assert(player._is_safe_reference_url("https://example.org/source"), "HTTPS references should be accepted") _assert(not player._is_safe_reference_url("http://example.org/source"), "HTTP references should be rejected") _assert(not player._is_safe_reference_url("javascript:alert(1)"), "script URLs should be rejected") player.references_container = VBoxContainer.new() player._show_references([ {"title": "Clinical guideline", "publisher": "Example Society", "url": "https://example.org/source"}, {"title": "Unsafe", "publisher": "Example", "url": "http://example.org/source"} ]) _assert(player.references_container.visible, "valid references should show the references section") _assert(player.references_container.get_child_count() == 2, "the section should contain a heading and one safe link") var link := player.references_container.get_child(1) as LinkButton _assert(link != null and link.text == "Clinical guideline - Example Society", "reference link text should identify its source") player.references_container.free() player.free() func _test_correct_feedback_does_not_auto_advance() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.next_button = Button.new() player.skip_button = Button.new() player.feedback_fog = ColorRect.new() player.pending_correct_advance = false player._trigger_correct_feedback() _assert(not player.pending_correct_advance, "correct feedback must not schedule automatic advancement") player.next_button.free() player.skip_button.free() player.feedback_fog.free() player.free() func _test_correct_review_locks_answer_interactions() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.current_answer = {"is_correct": true} player.selected_choice = "A" player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}] player._select_choice(1) _assert(player.selected_choice == "A", "correct review should reject answer selection changes") var right_click := InputEventMouseButton.new() right_click.button_index = MOUSE_BUTTON_RIGHT right_click.pressed = true player._choice_gui_input(right_click, 1) _assert(player.eliminated.is_empty(), "correct review should reject answer strike-through changes") player.free() func _test_eliminated_choice_uses_rich_text_strikeout() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.choice_buttons.append(Button.new()) player.choice_percent_labels.append(Label.new()) var strike_label := RichTextLabel.new() strike_label.bbcode_enabled = true player.choice_top_labels.append(strike_label) player.choice_followup_panels.append(PanelContainer.new()) player.eliminated = ["A"] player.presented_choices = [{"id": "A", "text": "Observation only"}] player._set_choice_button_text(0, player.presented_choices[0]) _assert(player.choice_buttons[0].text == "", "eliminated choice should not use combining characters in button text") _assert(strike_label.text == "[s]Observation only[/s]", "eliminated choice should use native rich-text strikeout") _assert(not "[eliminated]" in strike_label.text, "eliminated choice should not include a redundant label") player.choice_buttons[0].free() player.choice_percent_labels[0].free() strike_label.free() player.choice_followup_panels[0].free() player.free() func _test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.choice_buttons.append(Button.new()) player.choice_percent_labels.append(Label.new()) var strike_label := RichTextLabel.new() strike_label.bbcode_enabled = true player.choice_top_labels.append(strike_label) player.choice_followup_panels.append(PanelContainer.new()) player.eliminated = ["A"] player.locked_wrong_choice_ids = ["A"] player.presented_choices = [{"id": "A", "text": "Observation only"}] player._set_choice_button_text(0, player.presented_choices[0]) _assert(strike_label.text == "[s]Observation only[/s]", "a choice that's both eliminated and locked-wrong should still strike through") _assert(player.choice_buttons[0].text == "", "the struck-through choice's button text should stay cleared") player.choice_buttons[0].free() player.choice_percent_labels[0].free() strike_label.free() player.choice_followup_panels[0].free() player.free() func _test_explanation_highlighting_has_no_status_heading() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.explanation_label = RichTextLabel.new() player.explanation_label.bbcode_enabled = true player.explanation_plain_text = "The key finding supports observation." player.explanation_highlights[""] = [{"start": 4, "end": 15}] player._render_explanation() _assert(not player.explanation_label.text.begins_with("[b]Correct"), "explanation should not repeat the inferred correct status") _assert("[bgcolor=yellow]" in player.explanation_label.text, "explanation text should support highlighting") player.explanation_label.free() player.free() func _test_resolved_followup_stays_expanded_and_toggles() -> void: var player = load("res://scripts/ui/question_player.gd").new() var panel := PanelContainer.new() var content := VBoxContainer.new() panel.add_child(content) player.choice_buttons.append(Button.new()) player.choice_percent_labels.append(Label.new()) player.choice_top_labels.append(RichTextLabel.new()) player.choice_followup_panels.append(panel) player.choice_followup_contents.append(content) player.next_button = Button.new() player.presented_choices = [{"id": "B", "text": "Wrong answer"}] player.followup_states["B"] = { "choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?", "choices": [{"id": "A", "text": "Selected finding"}, {"id": "B", "text": "Correct finding"}], "correct_choice_id": "B", "selected_option_id": "A", "selected_option_text": "A. Selected finding", "was_correct": false, "teaching_point": "Incorrect. Review the finding.", "resolved": true, "collapsed": false, "has_authored_choices": true } player._render_followup_state("B") var toggle := content.find_child("FollowupToggle", true, false) as Button _assert(toggle != null, "a resolved follow-up should render a minimize toggle") _assert(toggle != null and toggle.text == "Minimize", "a newly resolved follow-up should remain expanded") _assert(content.find_child("FollowupPrompt", true, false) != null, "the resolved follow-up should retain its prompt") _assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option") _assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option") var verdict := content.find_child("FollowupVerdict", true, false) as Label _assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct") if toggle != null: toggle.pressed.emit() _assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up") _assert(content.find_child("FollowupPrompt", true, false) == null, "a collapsed follow-up should hide its prompt") toggle = content.find_child("FollowupToggle", true, false) as Button _assert(toggle != null and toggle.text == "Expand", "a collapsed follow-up should render an expand toggle") if toggle != null: toggle.pressed.emit() _assert(not bool(player.followup_states["B"].get("collapsed", true)), "the expand toggle should restore the follow-up") _assert(content.find_child("FollowupPrompt", true, false) != null, "expanding should restore the follow-up prompt") var correct_state: Dictionary = player.followup_states["B"] correct_state["selected_option_id"] = "B" correct_state["was_correct"] = true player.followup_states["B"] = correct_state player._render_followup_state("B") verdict = content.find_child("FollowupVerdict", true, false) as Label _assert(verdict != null and verdict.text == "Correct", "a correct follow-up response should show a correct verdict") player.choice_buttons[0].free() player.choice_percent_labels[0].free() player.choice_top_labels[0].free() player.next_button.free() panel.free() player.free() func _test_review_content_normalization() -> void: var service = load("res://scripts/autoload/question_bank_service.gd").new() var normalized: Dictionary = service._normalize_api_question({ "id": "mongo-id", "externalId": "review-test", "stem": "Stem", "leadIn": "Lead", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "A", "explanation": "Explanation", "keyTakeaway": "Takeaway", "usmlePearl": "Pearl", "reviewFollowups": [{ "prompt": "Prompt", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "B", "teachingPoint": "Teaching" }], "system": "System", "discipline": "Medicine" }) _assert(normalized.get("key_takeaway", "") == "Takeaway", "key takeaway should be normalized") _assert(normalized.get("usmle_pearl", "") == "Pearl", "USMLE pearl should be normalized") var review_followups: Array = normalized.get("review_followups", []) _assert(review_followups.size() == 1, "review follow-ups should be normalized") _assert(review_followups[0].get("correct_choice_id", "") == "B", "review follow-up answer keys should be normalized") service.free() func _test_followup_attempt_order_is_stable() -> void: var player = load("res://scripts/ui/question_player.gd").new() player._append_followup_attempt("C") player._append_followup_attempt("A") _assert(player.followup_attempt_order == ["C", "A"], "follow-up state should preserve answer attempt order") player._append_followup_attempt("C") _assert(player.followup_attempt_order == ["C", "A"], "repeat rendering should not duplicate an attempted answer") player.free() func _test_review_followup_is_optional_and_single_attempt() -> void: var session = root.get_node("BlockSessionService") var prior_questions: Array = session.active_questions.duplicate(true) var prior_index: int = session.current_index session.active_questions = [{ "review_followups": [{ "prompt": "Prompt", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correct_choice_id": "A", "teaching_point": "Teaching point" }] }] session.current_index = 0 var player = load("res://scripts/ui/question_player.gd").new() player.next_button = Button.new() player.next_button.disabled = false player.review_followups_section = VBoxContainer.new() var heading := Label.new() heading.name = "ReviewFollowupHeading" player.review_followups_section.add_child(heading) player.review_followups_content = VBoxContainer.new() player.review_followups_section.add_child(player.review_followups_content) player._render_review_followups() _assert(not player.next_button.disabled, "optional review follow-ups must not lock Next") player._answer_review_followup(0, "B") _assert(bool(player.review_followup_states[0].get("answered", false)), "a review follow-up should record its first response") player._answer_review_followup(0, "A") _assert(player.review_followup_states[0].get("selected_choice_id", "") == "B", "review follow-ups should accept only one attempt") player.review_followups_section.free() player.next_button.free() player.free() session.active_questions = prior_questions session.current_index = prior_index func _test_question_failure_retry_classification() -> void: var service = load("res://scripts/autoload/question_bank_service.gd").new() _assert(service._is_transient_question_failure(HTTPRequest.RESULT_CANT_CONNECT, 0), "connection failures should retry") _assert(service._is_transient_question_failure(HTTPRequest.RESULT_SUCCESS, 429), "rate limits should retry") _assert(service._is_transient_question_failure(HTTPRequest.RESULT_SUCCESS, 503), "server failures should retry") _assert(not service._is_transient_question_failure(HTTPRequest.RESULT_SUCCESS, 400), "invalid requests should not retry") _assert(not service._is_transient_question_failure(HTTPRequest.RESULT_SUCCESS, 401), "authentication failures should not retry") service.free() func _test_transient_question_failure_keeps_player_waiting() -> void: var question_bank = root.get_node("QuestionBankService") var prior_state: String = question_bank.load_state var player = load("res://scripts/ui/question_player.gd").new() player.next_button = Button.new() player.awaiting_next_question = true question_bank.load_state = "retrying" player._on_random_question_failed("Connection interrupted.") _assert(player.awaiting_next_question, "automatic retries should preserve the pending next-question request") _assert(player.next_button.disabled, "next button should remain disabled while reconnecting") _assert(player.next_button.text == "Reconnecting...", "retry state should be visible to the user") question_bank.load_state = prior_state player.next_button.free() player.free() func _test_feedback_context_excludes_study_content() -> void: var feedback = root.get_node("FeedbackService") var context: Dictionary = feedback.build_context("player", Vector2(1280, 720)) _assert(context.get("screen", "") == "player", "feedback context should identify the current screen") _assert(context.get("viewportWidth", 0) == 1280, "feedback context should include viewport width") _assert(context.get("viewportHeight", 0) == 720, "feedback context should include viewport height") _assert(not context.has("question"), "feedback context must not include question content") _assert(not context.has("answer"), "feedback context must not include answer content") _assert(not context.has("accessToken"), "feedback context must not include authentication tokens") func _test_feedback_form_validation() -> void: var dialog_script = load("res://scripts/ui/app_feedback_dialog.gd") _assert(dialog_script.is_valid_submission("bug", "The next button does not respond."), "valid feedback should be accepted") _assert(not dialog_script.is_valid_submission("unknown", "The next button does not respond."), "unknown categories should be rejected") _assert(not dialog_script.is_valid_submission("bug", "Too short"), "short feedback should be rejected") func _test_revisitable_navigation_prefers_unanswered_questions() -> void: var session = root.get_node("BlockSessionService") var prior_questions: Array = session.active_questions.duplicate(true) var prior_answers: Array = session.answers.duplicate(true) var prior_index: int = session.current_index session.active_questions = [{"id": "one"}, {"id": "two"}, {"id": "three"}] session.answers = [{"selected_choice_id": "A"}, null, null] session.current_index = 0 var player = load("res://scripts/ui/question_player.gd").new() _assert(player._next_revisitable_index() == 1, "skip should advance to the next unanswered question") session.answers[1] = {"selected_choice_id": "B"} session.current_index = 2 _assert(player._next_revisitable_index() == 0, "skip should wrap rather than reload the same unanswered question") session.answers = [{"selected_choice_id": "A"}, null, {"selected_choice_id": "C"}] session.current_index = 2 _assert(player._next_pending_simulation_index() == 1, "simulation should cycle an earlier skipped question back") session.answers[1] = {"selected_choice_id": "B"} _assert(player._next_pending_simulation_index() == -1, "simulation should submit only after every question has an answer") session.answers[2] = {"selected_choice_id": ""} session.current_index = 2 _assert(player._next_pending_simulation_index() == 2, "the only skipped simulation question should remain pending") player.free() session.active_questions = prior_questions session.answers = prior_answers session.current_index = prior_index func _test_timer_text_avoids_redundant_frame_updates() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.timer_label = Label.new() player._set_timer_display(65) _assert(player.timer_label.text == "01:05", "timer should render a changed second") player.timer_label.text = "unchanged" player._set_timer_display(65) _assert(player.timer_label.text == "unchanged", "timer should not rewrite its label every frame") player._set_timer_display(66) _assert(player.timer_label.text == "01:06", "timer should render the next changed second") player.timer_label.free() player.free() func _test_player_runtime_construction() -> void: var host := Control.new() host.size = Vector2(1280, 720) root.add_child(host) var player = load("res://scripts/ui/question_player.gd").new() player.main = host host.add_child(player) await process_frame _assert(player.question_card != null, "question player should construct its branded card") _assert(player.navigator_popup != null, "question player should construct its popup navigator") _assert(player.header_row.get_node_or_null("StepBadge") == null, "question player should not display a redundant STEP 3 badge") _assert(player.header_row.get_child(0) != player.progress_button, "the brand mark should be the first header item, ahead of question progress") _assert(player.progress_button.get_parent().get_parent() == player.header_row, "question progress should still live in the header row") _assert(player.progress_bar != null and player.progress_percent_label != null, "question player should construct a header progress bar and percentage label") _assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork") _assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork") _assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork") _assert(player.calculator_button != null and player.calculator_button.icon != null, "every question mode should expose the calculator tool") _assert(player.calculator_panel != null, "question player should construct the floating calculator once") _assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool") for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]: _assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered") _assert(icon_button.vertical_icon_alignment == VERTICAL_ALIGNMENT_CENTER, "question toolbar artwork should be vertically centered") _assert(icon_button.custom_minimum_size == Vector2(44, 44), "question toolbar artwork should keep stable square controls") player._style_asset_toolbar_button(player.clear_highlight_button, player.CLEAR_HIGHLIGHT_LIGHT_ICON, false) _assert(player.clear_highlight_button.icon == player.CLEAR_HIGHLIGHT_LIGHT_ICON, "clear highlights should support its light-mode artwork") player._style_asset_toolbar_button(player.report_button, player.REPORT_QUESTION_LIGHT_ICON, false) _assert(player.report_button.icon == player.REPORT_QUESTION_LIGHT_ICON, "report question should support its light-mode artwork") var toggle_box := player.theme_button.get_theme_stylebox("normal") as StyleBoxFlat _assert(player.theme_button.custom_minimum_size == Vector2(44, 44), "question theme toggle should retain the shared square footprint") _assert(toggle_box != null and toggle_box.corner_radius_top_left == 7, "question theme toggle should match the other toolbar icons' rounded-square treatment") _assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon") _assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot") _assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice") var long_choice := "Transfer to a neurocritical-care center, use a titratable agent to avoid severe hypertension and blood-pressure variability without causing hypotension, start enteral nimodipine, and secure the aneurysm as early as feasible." player.choice_buttons[0].text = long_choice await process_frame await process_frame _assert(player.choice_buttons[0].autowrap_mode == TextServer.AUTOWRAP_WORD_SMART, "long answer choices should use word-smart wrapping") _assert(player.choice_buttons[0].size.y > 68.0, "a wrapped answer choice should grow taller than the standard answer row") _assert(player.choice_buttons[0].size.x <= player.question_panel.size.x, "long answer choices should not widen the question player") _assert(player.next_button.get_global_rect().end.x <= player.get_global_rect().end.x + 0.5, "long answer choices should never push Submit beyond the viewport") var prior_session_kind: String = root.get_node("BlockSessionService").session_kind _assert(root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": null}), "legacy answered simulation responses should restore as submitted") _assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": false}), "new simulation drafts should remain editable after an in-session autosave") _assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "", "isSubmitted": null}), "legacy skipped simulation responses should remain revisitable") root.get_node("BlockSessionService").session_kind = "simulation" player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}] player.current_answer = {"selected_choice_id": "A", "is_submitted": true} player.selected_choice = "A" player._apply_saved_choice_state() _assert(player.choice_buttons[0].disabled and player.choice_buttons[1].disabled, "resumed submitted simulation answers should lock every choice") _assert(not player.next_button.disabled, "a resumed submitted answer should still allow navigation to the next question") player._select_choice(1) _assert(player.selected_choice == "A", "a resumed submitted simulation answer should not be changeable") player.current_answer = {} player.selected_choice = "" for session_kind in ["block", "core", "bookmark", "review", "simulation"]: root.get_node("BlockSessionService").session_kind = session_kind player._refresh_toolbar_state() _assert(player.calculator_button.visible, "%s question sessions should expose the calculator tool" % session_kind) root.get_node("BlockSessionService").session_kind = "core" player._refresh_toolbar_state() _assert(player.notes_button.visible, "practice question runs should show the notes tool") root.get_node("BlockSessionService").session_kind = "simulation" player._refresh_toolbar_state() _assert(not player.notes_button.visible, "timed simulations should hide the notes tool") root.get_node("BlockSessionService").session_kind = prior_session_kind player.choice_followup_panels[2].visible = true player.locked_wrong_choice_ids = ["C"] player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}, {"id": "C", "text": "Seek a court order"}] player._set_choice_button_text(2, player.presented_choices[2]) _assert(not player.choice_top_labels[2].visible, "nested remediation should not add a second label over the answer row") _assert(player.choice_buttons[2].text == "Seek a court order", "wrong-answer rows should retain their answer text") _assert(is_equal_approx(player.choice_badges[2].anchor_top, 0.0), "expanded answer badges should remain pinned to the answer strip") host.queue_free() await process_frame func _test_submit_styles_keep_stable_geometry() -> void: var player = load("res://scripts/ui/question_player.gd").new() var normal: StyleBoxFlat = player._primary_action_box(true, "normal") var disabled: StyleBoxFlat = player._primary_action_box(true, "disabled") _assert(normal.corner_radius_top_left == disabled.corner_radius_top_left, "enabled and disabled Submit styles should keep the same corner radius") _assert(is_equal_approx(normal.content_margin_top, disabled.content_margin_top), "enabled and disabled Submit styles should keep the same vertical margins") player.free() func _assert(condition: bool, message: String) -> void: if condition: return failures += 1 push_error(message)