From dca09d818f5d3f41fd6cc80ff70daeaf6d74a5aa Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 18 Jul 2026 14:57:33 -0500 Subject: [PATCH] Launch question bank directly from navigation --- scripts/ui/main.gd | 7 +- scripts/ui/navigation_routes.gd | 6 +- scripts/ui/question_player.gd | 7 +- scripts/ui/study_selector.gd | 112 ++++++++++++++++++++++++++++++-- tests/shell_navigation_test.gd | 6 +- tests/web_responsive_test.gd | 7 ++ 6 files changed, 131 insertions(+), 14 deletions(-) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index f223bb6..2ceb565 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -335,7 +335,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void container.add_child(HSeparator.new()) _add_nav_button(container, "home", "Home", "house", true) - _add_nav_button(container, "practice", "Practice", "pencil", true) + _add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true) _add_nav_button(container, "exams", "Exams", "clipboard-list", true) _add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true) _add_nav_button(container, "performance", "Performance", "chart-no-axes-column-increasing", true) @@ -379,7 +379,7 @@ func _add_nav_button(container: VBoxContainer, key: String, label_text: String, func _on_navigation_pressed(key: String) -> void: match key: "home": show_screen("selection", "exam") - "practice": show_screen("selection", "step3") + "question_bank": show_screen("selection", "question_bank") "exams": show_screen("simulation_history") "ccs": show_screen("ccs_hub") "performance": show_screen("profile") @@ -780,11 +780,12 @@ func _exit_run() -> void: var ccs_player = screens.get("ccs_player") if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case() return + var was_core_run := BlockSessionService.session_kind == "core" if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() elif BlockSessionService.session_kind == "simulation": BlockSessionService.block_started = false - show_screen("selection", "step3") + show_screen("selection", "exam" if was_core_run else "step3") func _open_profile_from_menu() -> void: diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index d1bf1c9..b9db6cf 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -4,9 +4,11 @@ extends RefCounted static func key_for_screen(screen_name: String, payload: Variant = null) -> String: var key := "home" if screen_name == "selection": - key = "practice" if str(payload) == "step3" else "home" + key = "question_bank" if str(payload) == "question_bank" else "home" elif screen_name in ["map", "setup", "review"]: - key = "study_plan" if screen_name == "map" else "practice" + key = "study_plan" if screen_name == "map" else "question_bank" + elif screen_name == "player": + key = "question_bank" elif screen_name.begins_with("simulation_"): key = "exams" elif screen_name.begins_with("ccs_"): diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index df11860..d77bb9f 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -975,7 +975,7 @@ func _load_question() -> void: var question := BlockSessionService.get_current_question() if question.is_empty(): - main.show_screen("selection", "step3") + main.show_screen("selection", "exam" if BlockSessionService.session_kind == "core" else "step3") return progress_button.text = _question_progress_text() progress_button.disabled = BlockSessionService.session_kind == "core" @@ -1603,16 +1603,17 @@ func _advance_or_finish() -> void: func _finish() -> void: if BlockSessionService.session_kind == "core": BlockSessionService.finish_block() - main.show_screen("selection", "step3") + main.show_screen("selection", "exam") return var results := BlockSessionService.finish_block() main.show_screen("review", results) func _exit_run() -> void: + var was_core_run := BlockSessionService.session_kind == "core" BlockSessionService.resume_current_question_timer() BlockSessionService.finish_block() - main.show_screen("selection", "step3") + main.show_screen("selection", "exam" if was_core_run else "step3") func _update_timer_bar() -> void: diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index 727f195..de61422 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -8,6 +8,7 @@ signal stage_changed(stage_name: String) var main: Control var exam_view: VBoxContainer var mode_view: VBoxContainer +var question_bank_launch_view: VBoxContainer var stage_scroll: ScrollContainer var stage_content: VBoxContainer var exam_grid: VBoxContainer @@ -17,6 +18,9 @@ var question_bank_button: Button var bank_status_label: Label var bank_error_label: Label var retry_button: Button +var launch_status_label: Label +var launch_error_label: Label +var launch_retry_button: Button var ccs_button: Button var ccs_status_label: Label var pending_ccs_launch := false @@ -51,12 +55,16 @@ func _ready() -> void: _build_exam_view() _build_mode_view() + _build_question_bank_launch_view() _show_stage("exam") func on_show(payload: Variant = null) -> void: if str(payload) == "step3": _show_stage("step3") + elif str(payload) == "question_bank": + _show_stage("question_bank") + call_deferred("_select_question_bank") elif str(payload) == "exam": _show_stage("exam") _refresh_bank_state() @@ -202,6 +210,84 @@ func _build_mode_view() -> void: ccs_status_label = ccs_card.status ccs_button.pressed.connect(_select_ccs) + +func _build_question_bank_launch_view() -> void: + question_bank_launch_view = VBoxContainer.new() + question_bank_launch_view.name = "QuestionBankLaunch" + question_bank_launch_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_bank_launch_view.size_flags_vertical = Control.SIZE_EXPAND_FILL + question_bank_launch_view.alignment = BoxContainer.ALIGNMENT_CENTER + stage_content.add_child(question_bank_launch_view) + + var center := CenterContainer.new() + center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center.size_flags_vertical = Control.SIZE_EXPAND_FILL + question_bank_launch_view.add_child(center) + + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(560, 300) + panel.set_meta("medmind_wide_minimum", Vector2(560, 300)) + panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.TEAL)) + panel.set_meta("medmind_role", "question_bank_launch") + themed_controls.append(panel) + responsive_cards.append(panel) + center.add_child(panel) + + var content := VBoxContainer.new() + content.alignment = BoxContainer.ALIGNMENT_CENTER + content.add_theme_constant_override("separation", 18) + panel.add_child(content) + + var icon := TextureRect.new() + icon.texture = preload("res://assets/icons/lucide/clipboard-list.svg") + icon.custom_minimum_size = Vector2(54, 54) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.modulate = Brand.TEAL if UIThemeService.is_dark_mode() else Brand.ROYAL_BLUE + icon.set_meta("medmind_role", "question_bank_launch_icon") + themed_controls.append(icon) + content.add_child(icon) + + var title := Label.new() + title.text = "Preparing your question bank" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 27) + content.add_child(title) + + launch_status_label = Label.new() + launch_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + launch_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + launch_status_label.add_theme_font_override("font", Brand.font) + launch_status_label.add_theme_font_size_override("font_size", 16) + launch_status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + launch_status_label.set_meta("medmind_role", "muted") + themed_controls.append(launch_status_label) + content.add_child(launch_status_label) + + launch_error_label = Label.new() + launch_error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + launch_error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + launch_error_label.add_theme_font_override("font", Brand.font) + launch_error_label.add_theme_font_size_override("font_size", 15) + launch_error_label.add_theme_color_override("font_color", Style.RED) + content.add_child(launch_error_label) + + var actions := HBoxContainer.new() + actions.alignment = BoxContainer.ALIGNMENT_CENTER + actions.add_theme_constant_override("separation", 12) + content.add_child(actions) + launch_retry_button = Button.new() + launch_retry_button.text = "Retry" + launch_retry_button.custom_minimum_size = Vector2(120, 44) + launch_retry_button.pressed.connect(_retry_question_bank) + actions.add_child(launch_retry_button) + var cancel := Button.new() + cancel.text = "Back to Home" + cancel.custom_minimum_size = Vector2(150, 44) + cancel.pressed.connect(func(): main.show_screen("selection", "exam")) + actions.add_child(cancel) + func _add_mode_card( title_text: String, subtitle_text: String, @@ -527,6 +613,8 @@ func refresh_theme_controls() -> void: match role: "mode_card": control.add_theme_stylebox_override("panel", _mode_card_box(enabled, accent)) "exam_card": control.add_theme_stylebox_override("panel", _exam_card_box(enabled)) + "question_bank_launch": control.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.TEAL)) + "question_bank_launch_icon": control.modulate = Brand.TEAL if dark else Brand.ROYAL_BLUE "icon_circle": control.add_theme_stylebox_override("panel", _circle_box(visual_accent, 36)) "arrow_circle": control.add_theme_stylebox_override("panel", _circle_box(visual_accent, 24, enabled)) "exam_icon_circle": control.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE if enabled else Brand.PURPLE, 39)) @@ -542,7 +630,7 @@ func refresh_theme_controls() -> void: "arrow_icon": control.modulate = Color.WHITE if dark else Brand.ROYAL_BLUE "exam_arrow": control.modulate = Brand.TEAL if dark and enabled else Brand.ROYAL_BLUE "back_bar": _style_back_bar(control as Button) - for node in [exam_view, mode_view]: + for node in [exam_view, mode_view, question_bank_launch_view]: if node != null: for child in node.find_children("*", "Label", true, false): var label := child as Label @@ -558,11 +646,15 @@ func _show_stage(stage_name: String) -> void: exam_view.visible = stage_name == "exam" if mode_view != null: mode_view.visible = stage_name == "step3" + if question_bank_launch_view != null: + question_bank_launch_view.visible = stage_name == "question_bank" _refresh_grid_columns() _refresh_bank_state() func _select_question_bank() -> void: + if pending_question_bank_launch: + return pending_question_bank_launch = true var excluded_ids: Array = ProfileService.get_profile().get("correct_question_ids", []).duplicate() QuestionBankService.load_next_random_question(excluded_ids) @@ -604,13 +696,16 @@ func _retry_question_bank() -> void: func _on_questions_changed() -> void: _refresh_bank_state() - if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready": + if pending_question_bank_launch and visible and current_stage in ["step3", "question_bank"] and QuestionBankService.load_state == "ready": pending_question_bank_launch = false call_deferred("_start_question_bank") func _on_questions_load_failed(_message: String) -> void: - pending_question_bank_launch = false + # Keep the launch intent alive while the service performs its automatic + # backoff retry. A terminal failure still waits for an explicit retry. + if QuestionBankService.load_state != "retrying": + pending_question_bank_launch = false _refresh_bank_state() @@ -621,8 +716,12 @@ func _refresh_bank_state() -> void: question_bank_button.disabled = pending_question_bank_launch retry_button.visible = state == "failed" or state == "retrying" retry_button.disabled = state == "retrying" + launch_retry_button.visible = state == "failed" or state == "retrying" + launch_retry_button.disabled = state == "retrying" bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else "" bank_error_label.visible = not bank_error_label.text.is_empty() + launch_error_label.text = bank_error_label.text + launch_error_label.visible = not launch_error_label.text.is_empty() if pending_question_bank_launch or state == "loading": bank_status_label.text = "Preparing your question bank..." elif state == "retrying": @@ -634,16 +733,21 @@ func _refresh_bank_state() -> void: else: bank_status_label.text = "" bank_status_label.visible = not bank_status_label.text.is_empty() + launch_status_label.text = bank_status_label.text + launch_status_label.visible = not launch_status_label.text.is_empty() func _start_question_bank() -> void: - if not visible or current_stage != "step3" or not QuestionBankService.has_questions(): + if not visible or current_stage not in ["step3", "question_bank"] or not QuestionBankService.has_questions(): return if BlockSessionService.start_core_run(): UISoundService.play_sound("question") main.show_screen("player") else: bank_error_label.text = BlockSessionService.core_status_message + bank_error_label.visible = true + launch_error_label.text = BlockSessionService.core_status_message + launch_error_label.visible = true func _refresh_grid_columns() -> void: diff --git a/tests/shell_navigation_test.gd b/tests/shell_navigation_test.gd index 0097d9a..90a4940 100644 --- a/tests/shell_navigation_test.gd +++ b/tests/shell_navigation_test.gd @@ -7,8 +7,10 @@ var failures := 0 func _initialize() -> void: _assert(NavigationRoutes.key_for_screen("selection", "exam") == "home", "exam selection should activate Home") - _assert(NavigationRoutes.key_for_screen("selection", "step3") == "practice", "Step 3 modes should activate Practice") - _assert(NavigationRoutes.key_for_screen("setup") == "practice", "block setup should activate Practice") + _assert(NavigationRoutes.key_for_screen("selection", "step3") == "home", "Step 3 mode selection should remain part of Home") + _assert(NavigationRoutes.key_for_screen("selection", "question_bank") == "question_bank", "direct question bank launch should activate Question Bank") + _assert(NavigationRoutes.key_for_screen("setup") == "question_bank", "block setup should activate Question Bank") + _assert(NavigationRoutes.key_for_screen("player") == "question_bank", "question player should map to Question Bank") _assert(NavigationRoutes.key_for_screen("simulation_results") == "exams", "simulation results should activate Exams") _assert(NavigationRoutes.key_for_screen("ccs_review") == "ccs", "CCS review should activate CCS Cases") _assert(NavigationRoutes.key_for_screen("profile") == "performance", "profile should activate Performance") diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index 1d9a1a9..85f146f 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -32,6 +32,10 @@ func _run() -> void: _assert(dynamic_icon != null, "dynamic questions should expose its mode icon") if dynamic_icon != null: _assert(dynamic_icon.texture.resource_path == "res://assets/icons/lucide/activity.svg", "dynamic questions should use a web-safe vector icon") + selector._show_stage("question_bank") + await process_frame + _assert(selector.question_bank_launch_view.visible and not selector.mode_view.visible, "direct question bank navigation should show only the focused launch state") + _assert(selector.launch_retry_button != null and selector.launch_error_label != null, "question bank launch should retain retry and error controls") var main_screen = load("res://scripts/ui/main.gd").new() main_screen.size = host.size @@ -43,6 +47,9 @@ func _run() -> void: _assert(main_screen.theme_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "the theme toggle icon should remain centered") _assert(main_screen.header_panel.find_child("HeaderStats", true, false) == null, "the application header should not display rating or role labels") _assert(not main_screen.account_chevron_button.visible, "nonessential account controls should collapse on phone-width headers") + var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button + _assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank") + _assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item") host.queue_free() await process_frame