Improve question review and image handling
This commit is contained in:
@@ -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<string> ExcludeExternalIds { get; set; } = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+265
-16
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b34pl1b02ehr6
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user