From 8aaf7d61e9d06fe4c6f037a313fdb94f038e619a Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 14 Jul 2026 16:02:14 -0500 Subject: [PATCH] Improve question review and image handling --- api/QuestionsApi/Models/QuestionRequests.cs | 1 + api/QuestionsApi/Services/QuestionStore.cs | 2 + scripts/autoload/question_bank_service.gd | 33 ++- scripts/ui/question_player.gd | 281 ++++++++++++++++++-- scripts/ui/study_selector.gd | 17 ++ tests/media_viewer_test.gd | 57 ++++ tests/media_viewer_test.gd.uid | 1 + tests/post_answer_review_test.gd | 16 ++ 8 files changed, 391 insertions(+), 17 deletions(-) create mode 100644 tests/media_viewer_test.gd create mode 100644 tests/media_viewer_test.gd.uid diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index f15d3e0..68a91c1 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -9,6 +9,7 @@ public class QuestionSearchRequest public int? DifficultyMax { get; set; } public int Limit { get; set; } = 40; public bool SupportsChoicePools { get; set; } + public bool RequireMedia { get; set; } public List ExcludeExternalIds { get; set; } = []; } diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index 445bada..b6a4432 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -37,6 +37,8 @@ public class QuestionStore filters.Add(builder.Gte(q => q.Difficulty, req.DifficultyMin.Value)); if (req.DifficultyMax is not null) filters.Add(builder.Lte(q => q.Difficulty, req.DifficultyMax.Value)); + if (req.RequireMedia) + filters.Add(builder.Exists("Media.0", true)); var excludedIds = req.ExcludeExternalIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Select(id => id.Trim()) diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index af22cfb..3007d1c 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -7,6 +7,7 @@ const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions" const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me" const DEFAULT_QUESTION_REPORTS_API_URL := "https://s3q.ranaze.com/api/question-reports" const RETRY_DELAYS := [2, 5, 10, 20, 30] +const IMAGE_QUESTION_SEARCH_LIMIT := 200 const ATTEMPT_QUEUE_PATH := "user://pending_question_attempts.json" signal questions_changed @@ -44,6 +45,8 @@ var attempt_queue: Array = [] var active_attempt_index := -1 var attempt_request_active := false var pending_excluded_question_ids: Array = [] +var pending_require_media := false +var image_question_search_attempts := 0 func _ready() -> void: questions_api_url = EndpointConfig.resolve( @@ -93,13 +96,15 @@ func _ready() -> void: _process_attempt_queue() -func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = []) -> void: +func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Array = [], require_media: bool = false) -> void: if AuthState.access_token.is_empty(): _set_load_error("Login required before loading questions.", false) return if reset_retry: pending_excluded_question_ids = excluded_question_ids.duplicate() + pending_require_media = require_media + image_question_search_attempts = 0 retry_attempt = 0 next_retry_seconds = 0 retry_timer.stop() @@ -120,6 +125,7 @@ func load_questions_from_api(reset_retry: bool = true, excluded_question_ids: Ar var payload := { "limit": 1, "supportsChoicePools": true, + "requireMedia": pending_require_media, "excludeExternalIds": pending_excluded_question_ids } var err := request.request(questions_api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) @@ -147,6 +153,17 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS if api_questions.is_empty(): _set_load_error("Questions API returned no usable questions.") return + if pending_require_media and not _contains_question_with_media(api_questions): + for question in api_questions: + var question_id := str(question.get("id", "")) + if question_id != "" and not pending_excluded_question_ids.has(question_id): + pending_excluded_question_ids.append(question_id) + image_question_search_attempts += api_questions.size() + if image_question_search_attempts >= IMAGE_QUESTION_SEARCH_LIMIT: + _set_load_error("No question with an image was found.", false) + return + call_deferred("load_questions_from_api", false, pending_excluded_question_ids, true) + return questions = api_questions last_error = "" @@ -168,6 +185,18 @@ func load_next_random_question(excluded_question_ids: Array) -> void: load_questions_from_api(true, excluded_question_ids) +func load_image_question_for_testing() -> void: + load_questions_from_api(true, [], true) + + +func _contains_question_with_media(question_list: Array) -> bool: + for question in question_list: + var media: Array = question.get("media", []) + if not media.is_empty(): + return true + return false + + func clear_questions() -> void: if request != null: request.cancel_request() @@ -195,6 +224,8 @@ func clear_questions() -> void: last_rating_change = {} pending_report_question_id = "" pending_excluded_question_ids.clear() + pending_require_media = false + image_question_search_attempts = 0 questions_changed.emit() ratings_changed.emit() diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 2aacdfb..8b97199 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -8,6 +8,9 @@ const TEXT_SCALE_MAX := 1.35 const TEXT_SCALE_STEP := 0.1 const REPORT_DETAILS_MIN := 10 const REPORT_DETAILS_MAX := 2000 +const MEDIA_ZOOM_MIN := 0.25 +const MEDIA_ZOOM_MAX := 4.0 +const MEDIA_ZOOM_FACTOR := 1.25 const REPORT_CATEGORIES := [ {"id": "clarity", "label": "Clarity"}, {"id": "medical_accuracy", "label": "Medical accuracy"}, @@ -84,6 +87,20 @@ var report_success_label: Label var report_is_submitting := false var report_question_id := "" var media_requests: Array[HTTPRequest] = [] +var question_media_items: Array = [] +var media_viewer_overlay: Control +var media_viewer_background: ColorRect +var media_viewer_scroll: ScrollContainer +var media_viewer_center: CenterContainer +var media_viewer_texture: TextureRect +var media_viewer_position_label: Label +var media_viewer_zoom_label: Label +var media_viewer_previous_button: Button +var media_viewer_next_button: Button +var media_viewer_index := -1 +var media_viewer_zoom := 1.0 +var media_viewer_dragging := false +var media_viewer_drag_position := Vector2.ZERO var red_fog_time := 0.0 var green_fog_time := 0.0 var correct_advance_delay := 0.0 @@ -331,7 +348,7 @@ func _ready() -> void: explanation_label.bbcode_enabled = true explanation_label.fit_content = true explanation_label.scroll_active = false - explanation_label.custom_minimum_size = Vector2(0, 150) + explanation_label.custom_minimum_size = Vector2.ZERO explanation_label.visible = false _set_scaled_rich_text(explanation_label, 17) question_content.add_child(explanation_label) @@ -381,6 +398,7 @@ func _ready() -> void: _build_labs_modal() _build_report_modal() + _build_media_viewer() resized.connect(_fit_modal_panels) _fit_modal_panels() @@ -403,6 +421,7 @@ func on_show(_payload: Variant = null) -> void: func on_hide() -> void: awaiting_next_question = false + _hide_media_viewer() _hide_report_modal(true) _hide_labs_modal() @@ -421,6 +440,21 @@ func _process(delta: float) -> void: func _input(event: InputEvent) -> void: + if media_viewer_overlay != null and media_viewer_overlay.visible: + if event.is_action_pressed("ui_cancel"): + _hide_media_viewer() + elif event.is_action_pressed("ui_left"): + _show_adjacent_media(-1) + elif event.is_action_pressed("ui_right"): + _show_adjacent_media(1) + elif event is InputEventKey and event.pressed and not event.echo and (event.keycode == KEY_PLUS or event.keycode == KEY_EQUAL): + _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR) + elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_MINUS: + _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR) + else: + return + get_viewport().set_input_as_handled() + return if report_overlay != null and report_overlay.visible and event.is_action_pressed("ui_cancel"): _hide_report_modal() get_viewport().set_input_as_handled() @@ -513,7 +547,7 @@ func _load_question() -> void: func _select_choice(index: int) -> void: - if pending_correct_advance or followup_required: + if pending_correct_advance or followup_required or _is_correct_answer_locked(): return var choices := _get_presented_choices() if index >= choices.size() or (BlockSessionService.mode == "Tutor" and not current_answer.is_empty()): @@ -529,7 +563,7 @@ func _select_choice(index: int) -> void: func _clear_selected_choice() -> void: - if selected_choice == "": + if selected_choice == "" or _is_correct_answer_locked(): return selected_choice = "" if BlockSessionService.mode == "Timed": @@ -596,7 +630,7 @@ func _skip_current_question() -> void: func _choice_gui_input(event: InputEvent, index: int) -> void: - if pending_correct_advance or followup_required: + if pending_correct_advance or followup_required or _is_correct_answer_locked(): return var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: @@ -622,7 +656,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: func _toggle_eliminate_selected() -> void: - if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty(): + if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty() or _is_correct_answer_locked(): return var choices := _get_presented_choices() for index in range(choices.size()): @@ -726,10 +760,7 @@ func _show_answer_review() -> void: skip_button.disabled = true skip_button.visible = false if question_scroll != null: - var review_target: Control = explanation_label - if references_container.visible: - review_target = references_container - question_scroll.call_deferred("ensure_control_visible", review_target) + question_scroll.call_deferred("ensure_control_visible", explanation_label) func _show_explanation() -> void: @@ -990,6 +1021,8 @@ func _refresh_toolbar_state() -> void: func refresh_theme_controls() -> void: _refresh_theme_button() + if media_viewer_background != null: + media_viewer_background.color = Style.BACKGROUND if labs_button != null: labs_button.icon = _make_labs_icon() if flag_button != null: @@ -1096,6 +1129,8 @@ func _load_question_media(question: Dictionary) -> void: var media_url := _resolve_media_url(str(item)) if media_url == "": continue + var media_index := question_media_items.size() + question_media_items.append({"texture": null, "loaded": false, "failed": false}) var frame := PanelContainer.new() frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL frame.add_theme_stylebox_override("panel", Style.panel_box(Color("111a2a"), Color("344463"))) @@ -1111,18 +1146,27 @@ func _load_question_media(question: Dictionary) -> void: _set_scaled_label(status, 15, Style.MUTED) image_box.add_child(status) + var preview := Control.new() + preview.visible = false + preview.custom_minimum_size = Vector2(0, 260) + preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL + preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + preview.tooltip_text = "Open enlarged image" + preview.gui_input.connect(func(event: InputEvent): _media_preview_gui_input(event, media_index)) + image_box.add_child(preview) + var texture_rect := TextureRect.new() texture_rect.visible = false - texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH + texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - texture_rect.custom_minimum_size = Vector2(0, 260) - texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL - image_box.add_child(texture_rect) + preview.add_child(texture_rect) var request := HTTPRequest.new() request.request_completed.connect( func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray): - _on_media_loaded(result, response_code, body, question_id, texture_rect, status) + _on_media_loaded(result, response_code, body, question_id, media_index, preview, texture_rect, status) ) media_requests.append(request) add_child(request) @@ -1132,10 +1176,12 @@ func _load_question_media(question: Dictionary) -> void: func _clear_question_media() -> void: + _hide_media_viewer() for request in media_requests: if is_instance_valid(request): request.queue_free() media_requests.clear() + question_media_items.clear() if media_container == null: return for child in media_container.get_children(): @@ -1167,12 +1213,16 @@ func _on_media_loaded( response_code: int, body: PackedByteArray, question_id: String, + media_index: int, + preview: Control, texture_rect: TextureRect, status: Label) -> void: if str(BlockSessionService.get_current_question().get("id", "")) != question_id: return if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: status.text = "Image unavailable." + if media_index < question_media_items.size(): + question_media_items[media_index]["failed"] = true return var image := Image.new() @@ -1183,13 +1233,207 @@ func _on_media_loaded( err = image.load_webp_from_buffer(body) if err != OK: status.text = "Image unavailable." + if media_index < question_media_items.size(): + question_media_items[media_index]["failed"] = true return - texture_rect.texture = ImageTexture.create_from_image(image) + var texture := ImageTexture.create_from_image(image) + question_media_items[media_index]["texture"] = texture + question_media_items[media_index]["loaded"] = true + texture_rect.texture = texture texture_rect.visible = true + preview.visible = true status.visible = false +func _build_media_viewer() -> void: + media_viewer_overlay = Control.new() + media_viewer_overlay.visible = false + media_viewer_overlay.top_level = true + media_viewer_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + media_viewer_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(media_viewer_overlay) + + media_viewer_background = ColorRect.new() + media_viewer_background.color = Style.BACKGROUND + media_viewer_background.mouse_filter = Control.MOUSE_FILTER_STOP + media_viewer_background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + media_viewer_overlay.add_child(media_viewer_background) + + var layout := VBoxContainer.new() + layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + layout.offset_left = 16 + layout.offset_top = 12 + layout.offset_right = -16 + layout.offset_bottom = -16 + layout.add_theme_constant_override("separation", 10) + media_viewer_overlay.add_child(layout) + + var toolbar := HBoxContainer.new() + toolbar.add_theme_constant_override("separation", 8) + layout.add_child(toolbar) + + media_viewer_position_label = Label.new() + media_viewer_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _set_scaled_label(media_viewer_position_label, 17, Style.TEXT) + toolbar.add_child(media_viewer_position_label) + + media_viewer_previous_button = _make_media_viewer_button("<", "Previous image") + media_viewer_previous_button.pressed.connect(func(): _show_adjacent_media(-1)) + toolbar.add_child(media_viewer_previous_button) + media_viewer_next_button = _make_media_viewer_button(">", "Next image") + media_viewer_next_button.pressed.connect(func(): _show_adjacent_media(1)) + toolbar.add_child(media_viewer_next_button) + + var zoom_out := _make_media_viewer_button("-", "Zoom out") + zoom_out.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR)) + toolbar.add_child(zoom_out) + media_viewer_zoom_label = Label.new() + media_viewer_zoom_label.custom_minimum_size = Vector2(64, 44) + media_viewer_zoom_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + media_viewer_zoom_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _set_scaled_label(media_viewer_zoom_label, 15, Style.TEXT) + toolbar.add_child(media_viewer_zoom_label) + var zoom_in := _make_media_viewer_button("+", "Zoom in") + zoom_in.pressed.connect(func(): _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR)) + toolbar.add_child(zoom_in) + var fit_button := _make_media_viewer_button("Fit", "Fit image to window", Vector2(60, 44)) + fit_button.pressed.connect(_fit_media_viewer_image) + toolbar.add_child(fit_button) + var close_button := _make_media_viewer_button("X", "Close image viewer") + close_button.pressed.connect(_hide_media_viewer) + toolbar.add_child(close_button) + + media_viewer_scroll = ScrollContainer.new() + media_viewer_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + media_viewer_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + media_viewer_scroll.gui_input.connect(_media_viewer_gui_input) + layout.add_child(media_viewer_scroll) + + media_viewer_center = CenterContainer.new() + media_viewer_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + media_viewer_center.size_flags_vertical = Control.SIZE_EXPAND_FILL + media_viewer_scroll.add_child(media_viewer_center) + + media_viewer_texture = TextureRect.new() + media_viewer_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + media_viewer_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + media_viewer_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE + media_viewer_center.add_child(media_viewer_texture) + + +func _make_media_viewer_button(text_value: String, tooltip: String, minimum := Vector2(44, 44)) -> Button: + var button := Button.new() + button.text = text_value + button.tooltip_text = tooltip + button.custom_minimum_size = minimum + Style.style_chip(button, Style.CYAN) + UISoundService.set_button_sound(button, "toggle") + return button + + +func _media_preview_gui_input(event: InputEvent, media_index: int) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _show_media_viewer(media_index) + accept_event() + elif event is InputEventScreenTouch and event.pressed: + _show_media_viewer(media_index) + accept_event() + + +func _show_media_viewer(index: int) -> void: + if index < 0 or index >= question_media_items.size(): + return + if not bool(question_media_items[index].get("loaded", false)): + return + var texture := question_media_items[index].get("texture") as Texture2D + if texture == null: + return + media_viewer_index = index + media_viewer_texture.texture = texture + media_viewer_overlay.visible = true + media_viewer_overlay.move_to_front() + _update_media_viewer_navigation() + call_deferred("_fit_media_viewer_image") + + +func _hide_media_viewer() -> void: + media_viewer_dragging = false + media_viewer_index = -1 + if media_viewer_overlay != null: + media_viewer_overlay.visible = false + + +func _show_adjacent_media(direction: int) -> void: + var next_index := _find_loaded_media_index(media_viewer_index, direction) + if next_index >= 0: + _show_media_viewer(next_index) + + +func _find_loaded_media_index(from_index: int, direction: int) -> int: + var index := from_index + direction + while index >= 0 and index < question_media_items.size(): + if bool(question_media_items[index].get("loaded", false)): + return index + index += direction + return -1 + + +func _update_media_viewer_navigation() -> void: + media_viewer_position_label.text = "Image %d of %d" % [media_viewer_index + 1, question_media_items.size()] + media_viewer_previous_button.disabled = _find_loaded_media_index(media_viewer_index, -1) < 0 + media_viewer_next_button.disabled = _find_loaded_media_index(media_viewer_index, 1) < 0 + + +func _fit_media_viewer_image() -> void: + if media_viewer_texture == null or media_viewer_texture.texture == null: + return + var image_size := media_viewer_texture.texture.get_size() + var available := media_viewer_scroll.size - Vector2(24, 24) + if image_size.x <= 0.0 or image_size.y <= 0.0 or available.x <= 0.0 or available.y <= 0.0: + return + _set_media_viewer_zoom(minf(available.x / image_size.x, available.y / image_size.y)) + + +func _set_media_viewer_zoom(value: float) -> void: + if media_viewer_texture == null or media_viewer_texture.texture == null: + return + media_viewer_zoom = clampf(value, MEDIA_ZOOM_MIN, MEDIA_ZOOM_MAX) + var scaled_size := media_viewer_texture.texture.get_size() * media_viewer_zoom + media_viewer_texture.custom_minimum_size = scaled_size + media_viewer_center.custom_minimum_size = Vector2( + maxf(media_viewer_scroll.size.x, scaled_size.x), + maxf(media_viewer_scroll.size.y, scaled_size.y)) + media_viewer_zoom_label.text = "%d%%" % int(round(media_viewer_zoom * 100.0)) + + +func _media_viewer_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + _set_media_viewer_zoom(media_viewer_zoom * MEDIA_ZOOM_FACTOR) + accept_event() + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + _set_media_viewer_zoom(media_viewer_zoom / MEDIA_ZOOM_FACTOR) + accept_event() + elif event.button_index == MOUSE_BUTTON_LEFT: + media_viewer_dragging = event.pressed + media_viewer_drag_position = event.position + accept_event() + elif event is InputEventMouseMotion and media_viewer_dragging: + var drag_delta: Vector2 = event.position - media_viewer_drag_position + media_viewer_drag_position = event.position + media_viewer_scroll.scroll_horizontal -= int(drag_delta.x) + media_viewer_scroll.scroll_vertical -= int(drag_delta.y) + accept_event() + elif event is InputEventMagnifyGesture: + _set_media_viewer_zoom(media_viewer_zoom * event.factor) + accept_event() + elif event is InputEventScreenDrag: + media_viewer_scroll.scroll_horizontal -= int(event.relative.x) + media_viewer_scroll.scroll_vertical -= int(event.relative.y) + accept_event() + + func _build_report_modal() -> void: report_overlay = Control.new() report_overlay.visible = false @@ -1639,11 +1883,12 @@ func _subtract_highlight_range(highlights: Array, start: int, end: int) -> bool: func _apply_saved_choice_state() -> void: var choices := _get_presented_choices() + var answer_is_locked := _is_correct_answer_locked() for index in range(choices.size()): var id := str(choices[index].get("id", "")) var has_followup_panel := index < choice_followup_panels.size() and choice_followup_panels[index].visible _set_choice_button_text(index, choices[index]) - choice_buttons[index].disabled = locked_wrong_choice_ids.has(id) and not has_followup_panel + choice_buttons[index].disabled = answer_is_locked or (locked_wrong_choice_ids.has(id) and not has_followup_panel) _style_choice_button_for_state(index, id) if locked_wrong_choice_ids.has(id): choice_buttons[index].modulate = Color(1.0, 0.25, 0.25) if Style.is_dark else Color.WHITE @@ -1665,6 +1910,10 @@ func _apply_saved_choice_state() -> void: _refresh_submit_state() +func _is_correct_answer_locked() -> bool: + return bool(current_answer.get("is_correct", false)) + + func _refresh_submit_state() -> void: if next_button == null: return diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index ddc8343..bd6c4b4 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -11,6 +11,7 @@ var question_bank_button: Button var bank_status_label: Label var bank_error_label: Label var retry_button: Button +var image_question_test_link: LinkButton var current_stage := "exam" var pending_question_bank_launch := false @@ -152,6 +153,15 @@ func _build_mode_view() -> void: retry_button.pressed.connect(_retry_question_bank) mode_view.add_child(retry_button) + image_question_test_link = LinkButton.new() + image_question_test_link.text = "Temporary: test a question with an image" + image_question_test_link.tooltip_text = "Load a random published question that includes media" + image_question_test_link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + image_question_test_link.add_theme_color_override("font_color", Style.CYAN) + image_question_test_link.add_theme_color_override("font_hover_color", Style.MAGENTA) + image_question_test_link.pressed.connect(_select_image_question_for_testing) + mode_view.add_child(image_question_test_link) + var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL mode_view.add_child(spacer) @@ -196,6 +206,12 @@ func _retry_question_bank() -> void: _refresh_bank_state() +func _select_image_question_for_testing() -> void: + pending_question_bank_launch = true + QuestionBankService.load_image_question_for_testing() + _refresh_bank_state() + + func _on_questions_changed() -> void: _refresh_bank_state() if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready": @@ -213,6 +229,7 @@ func _refresh_bank_state() -> void: return var state := QuestionBankService.load_state question_bank_button.disabled = pending_question_bank_launch + image_question_test_link.disabled = pending_question_bank_launch retry_button.visible = state == "failed" or state == "retrying" retry_button.disabled = state == "retrying" bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else "" diff --git a/tests/media_viewer_test.gd b/tests/media_viewer_test.gd new file mode 100644 index 0000000..7d10a1f --- /dev/null +++ b/tests/media_viewer_test.gd @@ -0,0 +1,57 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + _test_loaded_media_navigation() + _test_zoom_limits() + _test_image_question_detection() + quit(1 if failures > 0 else 0) + + +func _test_loaded_media_navigation() -> void: + var player = load("res://scripts/ui/question_player.gd").new() + player.question_media_items = [ + {"loaded": true}, + {"loaded": false}, + {"loaded": true} + ] + _assert(player._find_loaded_media_index(0, 1) == 2, "navigation should skip images that have not loaded") + _assert(player._find_loaded_media_index(2, -1) == 0, "back navigation should preserve source order") + _assert(player._find_loaded_media_index(2, 1) == -1, "navigation should stop at the final image") + player.free() + + +func _test_zoom_limits() -> void: + var player = load("res://scripts/ui/question_player.gd").new() + player.media_viewer_scroll = ScrollContainer.new() + player.media_viewer_scroll.size = Vector2(800, 600) + player.media_viewer_center = CenterContainer.new() + player.media_viewer_texture = TextureRect.new() + player.media_viewer_zoom_label = Label.new() + var image := Image.create(100, 50, false, Image.FORMAT_RGBA8) + player.media_viewer_texture.texture = ImageTexture.create_from_image(image) + player._set_media_viewer_zoom(20.0) + _assert(is_equal_approx(player.media_viewer_zoom, 4.0), "zoom should be capped at 400 percent") + player._set_media_viewer_zoom(0.01) + _assert(is_equal_approx(player.media_viewer_zoom, 0.25), "zoom should be capped at 25 percent") + player.media_viewer_scroll.free() + player.media_viewer_center.free() + player.media_viewer_texture.free() + player.media_viewer_zoom_label.free() + player.free() + + +func _test_image_question_detection() -> void: + var service = load("res://scripts/autoload/question_bank_service.gd").new() + _assert(not service._contains_question_with_media([{"id": "plain", "media": []}]), "plain questions should not satisfy an image-only request") + _assert(service._contains_question_with_media([{"id": "image", "media": ["/media/example"]}]), "questions with media should satisfy an image-only request") + service.free() + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/media_viewer_test.gd.uid b/tests/media_viewer_test.gd.uid new file mode 100644 index 0000000..80827e9 --- /dev/null +++ b/tests/media_viewer_test.gd.uid @@ -0,0 +1 @@ +uid://b34pl1b02ehr6 diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index f4cb244..9b05e63 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -7,6 +7,7 @@ func _initialize() -> void: _test_reference_normalization() _test_reference_url_filter() _test_correct_feedback_does_not_auto_advance() + _test_correct_review_locks_answer_interactions() quit(1 if failures > 0 else 0) @@ -58,6 +59,21 @@ func _test_correct_feedback_does_not_auto_advance() -> void: 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 _assert(condition: bool, message: String) -> void: if condition: return