Files
step3/tests/post_answer_review_test.gd
T
hz 4db5b9d374 Drop the redundant status icon and "Review: B..." label from follow-up panels
The choice button above the panel already shows the letter badge,
choice text, and percentage, so repeating them in the panel header
was redundant now that the panel is docked inside the choice's card.
Keeps the collapse toggle, now named FollowupToggle since the old
FollowupSummary button it lived on is gone.
2026-07-23 01:47:11 -05:00

421 lines
22 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_eliminated_choice_that_is_also_locked_wrong_still_strikes_through()
_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_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_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("FollowupToggle", true, false) as Button
_assert(review_button != null, "collapsed follow-up should render a toggle 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)