Files
step3/tests/post_answer_review_test.gd
T
2026-07-18 22:22:29 -05:00

399 lines
21 KiB
GDScript

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_explanation_highlighting_has_no_status_heading()
_test_collapsed_followup_review_expands()
_test_review_content_normalization()
_test_followup_attempt_order_is_stable()
_test_answer_review_collapses_nested_wrong_followups()
_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_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_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_collapsed_followup_review_expands() -> 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?",
"selected_option_text": "Finding", "was_correct": false,
"teaching_point": "Review the finding.", "resolved": true, "collapsed": true
}
player._render_followup_state("B")
var review_button := content.find_child("FollowupSummary", true, false) as Button
_assert(review_button != null, "collapsed follow-up should render a review button")
if review_button != null:
review_button.pressed.emit()
_assert(not bool(player.followup_states["B"].get("collapsed", true)), "review button should expand the resolved follow-up")
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_answer_review_collapses_nested_wrong_followups() -> void:
var player = load("res://scripts/ui/question_player.gd").new()
player.followup_states["C"] = {"resolved": true, "collapsed": false, "is_main_correct": false}
player.followup_attempt_order = ["C"]
player._ensure_answer_review_followups()
_assert(bool(player.followup_states["C"].get("collapsed", false)), "resolved distractor remediation should collapse for final review")
_assert(not player.followup_states.has("B"), "the correct answer should not become a distractor-remediation card")
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")
player.free()
session.active_questions = prior_questions
session.answers = prior_answers
session.current_index = prior_index
func _test_player_runtime_construction() -> void:
var host := Control.new()
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, "question progress should be the first header item")
_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.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 == 22, "question theme toggle should use the shared circular 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 prior_session_kind: String = root.get_node("BlockSessionService").session_kind
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 = "core"
player._refresh_toolbar_state()
_assert(player.notes_button.visible, "practice question runs should show 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)