197 lines
9.4 KiB
GDScript
197 lines
9.4 KiB
GDScript
extends SceneTree
|
|
|
|
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_question_failure_retry_classification()
|
|
_test_transient_question_failure_keeps_player_waiting()
|
|
_test_feedback_context_excludes_study_content()
|
|
_test_feedback_form_validation()
|
|
quit(1 if failures > 0 else 0)
|
|
|
|
|
|
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]A. 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.get_child(0) as Button
|
|
_assert(review_button != null, "collapsed follow-up should render a review button")
|
|
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_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 _assert(condition: bool, message: String) -> void:
|
|
if condition:
|
|
return
|
|
failures += 1
|
|
push_error(message)
|