From c8bfc26645a199608a40cb6336a5c145605a1822 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 16 Jul 2026 21:46:56 -0500 Subject: [PATCH] Implement nested question remediation and review content --- api/QuestionsApi.Tests/ChoicePoolTests.cs | 56 + api/QuestionsApi/Models/Question.cs | 11 + api/QuestionsApi/Models/QuestionReport.cs | 3 + api/QuestionsApi/Models/QuestionRequests.cs | 3 + api/QuestionsApi/Models/QuestionResponse.cs | 6 + .../Services/QuestionReportStore.cs | 3 + api/QuestionsApi/Services/QuestionStore.cs | 3 + .../Services/QuestionValidator.cs | 15 + scripts/autoload/block_session_service.gd | 12 + scripts/autoload/question_bank_service.gd | 19 + scripts/ui/main.gd | 7 +- scripts/ui/question_player.gd | 1294 ++++++++++++++--- tests/choice_pool_test.gd | 4 + tests/post_answer_review_test.gd | 148 +- 14 files changed, 1339 insertions(+), 245 deletions(-) diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index 88b3e41..f5a8eef 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -30,6 +30,9 @@ public sealed class ChoicePoolTests Assert.Equal(9, response.Choices.Count); Assert.Single(response.References); Assert.Equal("https://example.org/guideline", response.References[0].Url); + Assert.Equal("Key teaching point", response.KeyTakeaway); + Assert.Equal("Exam pearl", response.UsmlePearl); + Assert.Single(response.ReviewFollowups); } [Fact] @@ -54,6 +57,40 @@ public sealed class ChoicePoolTests Assert.Contains("every incorrect choice", QuestionValidator.Validate(request)); } + [Fact] + public void OptionalReviewFollowupsRequireValidAuthoredContent() + { + var request = BuildRequest(5); + Assert.Null(QuestionValidator.Validate(request)); + + request.ReviewFollowups[0].CorrectChoiceId = "missing"; + Assert.Contains("reviewFollowups correctChoiceId", QuestionValidator.Validate(request)); + + request = BuildRequest(5); + request.ReviewFollowups[0].TeachingPoint = ""; + Assert.Contains("reviewFollowups teachingPoint", QuestionValidator.Validate(request)); + } + + [Fact] + public void LegacyQuestionWithoutReviewContentRemainsValid() + { + var request = BuildRequest(5); + request.KeyTakeaway = ""; + request.UsmlePearl = ""; + request.ReviewFollowups = []; + + Assert.Null(QuestionValidator.Validate(request)); + } + + [Fact] + public void ActiveSimulationQuestionContractDoesNotExposeReviewContent() + { + Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.Explanation))); + Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.KeyTakeaway))); + Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.UsmlePearl))); + Assert.Null(typeof(ExamSimulationQuestionResponse).GetProperty(nameof(QuestionResponse.ReviewFollowups))); + } + [Fact] public void PublishedQuestionRequiresAtLeastFiveUniqueChoices() { @@ -121,6 +158,9 @@ public sealed class ChoicePoolTests Choices = request.Choices, CorrectChoiceId = request.CorrectChoiceId, Explanation = request.Explanation, + KeyTakeaway = request.KeyTakeaway, + UsmlePearl = request.UsmlePearl, + ReviewFollowups = request.ReviewFollowups, ChoiceExplanations = request.ChoiceExplanations, WrongAnswerFollowups = request.WrongAnswerFollowups, ChoiceSelectionPercentages = request.ChoiceSelectionPercentages, @@ -140,6 +180,22 @@ public sealed class ChoicePoolTests Choices = choices, CorrectChoiceId = "A", Explanation = "Explanation", + KeyTakeaway = "Key teaching point", + UsmlePearl = "Exam pearl", + ReviewFollowups = + [ + new QuestionReviewFollowup + { + Prompt = "Which statement best reinforces the concept?", + Choices = + [ + new QuestionChoice { Id = "A", Text = "Accurate statement" }, + new QuestionChoice { Id = "B", Text = "Inaccurate statement" } + ], + CorrectChoiceId = "A", + TeachingPoint = "Review teaching point" + } + ], ChoiceExplanations = ids.ToDictionary(id => id, id => id == "A" ? "Correct." : "Incorrect."), WrongAnswerFollowups = ids.Where(id => id != "A").ToDictionary( id => id, diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index 32ce8bd..f621fc1 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -15,6 +15,9 @@ public class Question public List Choices { get; set; } = []; public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List ReviewFollowups { get; set; } = []; public Dictionary ChoiceExplanations { get; set; } = []; public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; @@ -53,6 +56,14 @@ public class WrongAnswerFollowup public string TeachingPoint { get; set; } = ""; } +public class QuestionReviewFollowup +{ + public string Prompt { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string TeachingPoint { get; set; } = ""; +} + public class QuestionReference { public string Title { get; set; } = ""; diff --git a/api/QuestionsApi/Models/QuestionReport.cs b/api/QuestionsApi/Models/QuestionReport.cs index f8bc829..d8c910d 100644 --- a/api/QuestionsApi/Models/QuestionReport.cs +++ b/api/QuestionsApi/Models/QuestionReport.cs @@ -59,6 +59,9 @@ public class QuestionReportSnapshot public List Choices { get; set; } = []; public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List ReviewFollowups { get; set; } = []; public Dictionary ChoiceExplanations { get; set; } = []; public Dictionary WrongAnswerFollowups { get; set; } = []; public List Labs { get; set; } = []; diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 68a91c1..0f86b55 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -21,6 +21,9 @@ public class UpsertQuestionRequest public List Choices { get; set; } = []; public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List ReviewFollowups { get; set; } = []; public Dictionary ChoiceExplanations { get; set; } = []; public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 287f874..e97172e 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -9,6 +9,9 @@ public class QuestionResponse public List Choices { get; set; } = []; public string CorrectChoiceId { get; set; } = ""; public string Explanation { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List ReviewFollowups { get; set; } = []; public Dictionary ChoiceExplanations { get; set; } = []; public Dictionary WrongAnswerFollowups { get; set; } = []; public Dictionary ChoiceSelectionPercentages { get; set; } = []; @@ -41,6 +44,9 @@ public class QuestionResponse Choices = choices, CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, + KeyTakeaway = question.KeyTakeaway, + UsmlePearl = question.UsmlePearl, + ReviewFollowups = question.ReviewFollowups, ChoiceExplanations = question.ChoiceExplanations .Where(entry => choiceIds.Contains(entry.Key)) .ToDictionary(), diff --git a/api/QuestionsApi/Services/QuestionReportStore.cs b/api/QuestionsApi/Services/QuestionReportStore.cs index 0935a49..a082097 100644 --- a/api/QuestionsApi/Services/QuestionReportStore.cs +++ b/api/QuestionsApi/Services/QuestionReportStore.cs @@ -45,6 +45,9 @@ public class QuestionReportStore .ToList(), CorrectChoiceId = question.CorrectChoiceId, Explanation = question.Explanation, + KeyTakeaway = question.KeyTakeaway, + UsmlePearl = question.UsmlePearl, + ReviewFollowups = question.ReviewFollowups, ChoiceExplanations = new Dictionary(question.ChoiceExplanations), WrongAnswerFollowups = question.WrongAnswerFollowups.ToDictionary( item => item.Key, diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index d8bbb41..710e89a 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -206,6 +206,9 @@ public class QuestionStore Choices = req.Choices, CorrectChoiceId = req.CorrectChoiceId.Trim(), Explanation = req.Explanation.Trim(), + KeyTakeaway = req.KeyTakeaway.Trim(), + UsmlePearl = req.UsmlePearl.Trim(), + ReviewFollowups = req.ReviewFollowups, ChoiceExplanations = req.ChoiceExplanations, WrongAnswerFollowups = req.WrongAnswerFollowups, ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, diff --git a/api/QuestionsApi/Services/QuestionValidator.cs b/api/QuestionsApi/Services/QuestionValidator.cs index aef801e..bf3e117 100644 --- a/api/QuestionsApi/Services/QuestionValidator.cs +++ b/api/QuestionsApi/Services/QuestionValidator.cs @@ -50,6 +50,21 @@ public static class QuestionValidator return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id"; } + if (req.ReviewFollowups is null) return "reviewFollowups must be an array"; + if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups"; + foreach (var followup in req.ReviewFollowups) + { + if (string.IsNullOrWhiteSpace(followup.Prompt)) return "reviewFollowups prompt required"; + if (string.IsNullOrWhiteSpace(followup.TeachingPoint)) return "reviewFollowups teachingPoint required"; + if (followup.Choices.Count < 2) return "reviewFollowups require at least two choices"; + if (followup.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text))) + return "reviewFollowups choice ids and text are required"; + if (followup.Choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != followup.Choices.Count) + return "reviewFollowups choice ids must be unique"; + if (!followup.Choices.Any(choice => choice.Id == followup.CorrectChoiceId)) + return "reviewFollowups correctChoiceId must match a follow-up choice id"; + } + if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 8ccc4bc..9adcc87 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -8,6 +8,7 @@ var current_index := 0 var mode := "Tutor" var system_name := "All Systems" var seconds_remaining := 0.0 +var session_initial_seconds := 0.0 var block_started := false var session_kind := "block" var core_question_pool: Array = [] @@ -39,6 +40,7 @@ func start_block(selected_system: String, block_size: int, selected_mode: String answers.resize(active_questions.size()) current_index = 0 seconds_remaining = float(active_questions.size() * 90) + session_initial_seconds = seconds_remaining block_started = true session_changed.emit() @@ -52,6 +54,7 @@ func start_core_run() -> bool: core_question_pool.clear() current_index = -1 seconds_remaining = 0.0 + session_initial_seconds = core_current_target_seconds block_started = true core_answer_count = 0 core_score = 0 @@ -82,6 +85,7 @@ func start_simulation_block(questions: Array, block_index: int, total_blocks: in simulation_total_blocks = total_blocks simulation_deadline_unix = deadline_unix seconds_remaining = maxf(0.0, deadline_unix - Time.get_unix_time_from_system()) + session_initial_seconds = maxf(seconds_remaining, float(active_questions.size() * 90)) block_started = true current_question_started_at = Time.get_ticks_msec() / 1000.0 session_changed.emit() @@ -335,6 +339,14 @@ func get_core_timer_ratio() -> float: return clamp(ratio, 0.0, 1.0) +func get_session_timer_ratio() -> float: + if session_kind == "core": + return get_core_timer_ratio() + if session_initial_seconds <= 0.0: + return 1.0 + return clamp(seconds_remaining / session_initial_seconds, 0.0, 1.0) + + func pause_current_question_timer() -> void: if session_kind == "core" and current_question_pause_started_at <= 0.0: current_question_pause_started_at = Time.get_ticks_msec() / 1000.0 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 3cfb6f6..c877ffd 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -652,6 +652,9 @@ func _normalize_api_question(item: Variant) -> Dictionary: "choices": item.get("choices", []), "correct_choice_id": str(item.get("correctChoiceId", item.get("correct_choice_id", ""))), "explanation": str(item.get("explanation", "")), + "key_takeaway": str(item.get("keyTakeaway", item.get("key_takeaway", ""))), + "usmle_pearl": str(item.get("usmlePearl", item.get("usmle_pearl", ""))), + "review_followups": _normalize_review_followups(item.get("reviewFollowups", item.get("review_followups", []))), "choice_explanations": item.get("choiceExplanations", item.get("choice_explanations", {})), "wrong_answer_followups": item.get("wrongAnswerFollowups", item.get("wrong_answer_followups", {})), "choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})), @@ -668,6 +671,22 @@ func _normalize_api_question(item: Variant) -> Dictionary: return normalized +func _normalize_review_followups(raw_followups: Variant) -> Array: + var normalized: Array = [] + if typeof(raw_followups) != TYPE_ARRAY: + return normalized + for raw_followup in raw_followups: + if typeof(raw_followup) != TYPE_DICTIONARY: + continue + normalized.append({ + "prompt": str(raw_followup.get("prompt", "")).strip_edges(), + "choices": raw_followup.get("choices", []), + "correct_choice_id": str(raw_followup.get("correctChoiceId", raw_followup.get("correct_choice_id", ""))), + "teaching_point": str(raw_followup.get("teachingPoint", raw_followup.get("teaching_point", ""))).strip_edges() + }) + return normalized + + func _normalize_references(raw_references: Variant) -> Array: var normalized: Array = [] if typeof(raw_references) != TYPE_ARRAY: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index f48f405..c4e1a07 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -87,11 +87,12 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: func _apply_screen_margins(screen_name: String) -> void: if screens_margin == null or screen_name.is_empty(): return - var margin := 0 if screen_name == "login" else (20 if compact_shell else 44) + var is_fullscreen_study := screen_name == "player" + var margin := 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 44)) screens_margin.add_theme_constant_override("margin_left", margin) screens_margin.add_theme_constant_override("margin_right", margin) - screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (20 if compact_shell else 34)) - screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else 28) + screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else (12 if is_fullscreen_study else (20 if compact_shell else 34))) + screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else (12 if is_fullscreen_study else 28)) func _build_ui() -> void: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 4acbe28..620c411 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1,6 +1,7 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") const FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 @@ -23,6 +24,7 @@ const REPORT_CATEGORIES := [ var main: Control var theme_service var choice_buttons: Array[Button] = [] +var choice_rows: Array[VBoxContainer] = [] var choice_percent_labels: Array[Label] = [] var choice_top_labels: Array[RichTextLabel] = [] var choice_followup_panels: Array[PanelContainer] = [] @@ -30,6 +32,7 @@ var choice_followup_contents: Array[VBoxContainer] = [] var eliminated: Array = [] var locked_wrong_choice_ids: Array = [] var followup_states := {} +var followup_attempt_order: Array = [] var active_followup_choice_id := "" var followup_required := false var followup_answered := false @@ -49,13 +52,22 @@ var presented_choices: Array = [] var awaiting_next_question := false var simulation_submit_pending := false -var progress_label: Label +var progress_button: Button var timer_label: Label var player_stats_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var references_container: VBoxContainer +var correct_review_panel: PanelContainer +var correct_answer_heading: Label +var teaching_cards: HFlowContainer +var review_followups_section: VBoxContainer +var review_followups_content: VBoxContainer +var wrong_options_section: VBoxContainer +var wrong_options_content: VBoxContainer +var review_followup_states: Array = [] +var wrong_option_expanded := {} var question_scroll: ScrollContainer var question_elo_label: Label var next_button: Button @@ -67,11 +79,20 @@ var flag_button: Button var report_button: Button var app_feedback_button: Button var theme_button: Button -var question_toolbar: HBoxContainer -var nav_palette: HBoxContainer +var question_toolbar: HFlowContainer +var header_stack: VBoxContainer +var header_row: HBoxContainer +var header_toolbar_host: HBoxContainer +var nav_palette: GridContainer +var navigator_popup: PopupPanel +var question_card: PanelContainer +var question_panel: PanelContainer +var actions: HBoxContainer +var timer_footer: HBoxContainer var question_content: VBoxContainer var media_container: VBoxContainer var timer_bar: ProgressBar +var choice_badges: Array[Label] = [] var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer @@ -140,31 +161,64 @@ func _ready() -> void: if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed): ExamSimulationService.request_failed.connect(_on_simulation_request_failed) - var top := HBoxContainer.new() - top.add_theme_constant_override("separation", 12) - progress_label = Label.new() - _set_scaled_label(progress_label, 18, Style.CYAN) - progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - top.add_child(progress_label) - timer_label = Label.new() - _set_scaled_label(timer_label, 18, Style.AMBER) - timer_label.visible = false - timer_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - top.add_child(timer_label) + question_card = PanelContainer.new() + question_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_card.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(question_card) - var toolbar_spacer := Control.new() - toolbar_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - top.add_child(toolbar_spacer) + var card_margin := MarginContainer.new() + card_margin.add_theme_constant_override("margin_left", 18) + card_margin.add_theme_constant_override("margin_right", 18) + card_margin.add_theme_constant_override("margin_top", 14) + card_margin.add_theme_constant_override("margin_bottom", 12) + question_card.add_child(card_margin) + + var card_content := VBoxContainer.new() + card_content.add_theme_constant_override("separation", 10) + card_margin.add_child(card_content) + + header_stack = VBoxContainer.new() + header_stack.add_theme_constant_override("separation", 8) + card_content.add_child(header_stack) + header_row = HBoxContainer.new() + header_row.add_theme_constant_override("separation", 10) + header_stack.add_child(header_row) + + var step_badge := Label.new() + step_badge.text = "STEP 3" + step_badge.name = "StepBadge" + step_badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + step_badge.custom_minimum_size = Vector2(70, 34) + _set_scaled_label(step_badge, 13, Brand.PURPLE) + header_row.add_child(step_badge) + + progress_button = Button.new() + progress_button.flat = true + progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + progress_button.custom_minimum_size = Vector2(126, 34) + progress_button.tooltip_text = "Open question navigator" + _set_scaled_button_font(progress_button, 14) + progress_button.pressed.connect(_toggle_question_navigator) + header_row.add_child(progress_button) + + var header_spacer := Control.new() + header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_row.add_child(header_spacer) + + timer_label = Label.new() + _set_scaled_label(timer_label, 14, Brand.SLATE) + timer_label.visible = false + timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT player_stats_label = Label.new() player_stats_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _set_scaled_label(player_stats_label, 15, Style.MUTED) - top.add_child(player_stats_label) + _set_scaled_label(player_stats_label, 13, Brand.SLATE) + header_row.add_child(player_stats_label) - question_toolbar = HBoxContainer.new() - question_toolbar.add_theme_constant_override("separation", 8) - question_toolbar.alignment = BoxContainer.ALIGNMENT_END + question_toolbar = HFlowContainer.new() + question_toolbar.add_theme_constant_override("h_separation", 8) + question_toolbar.add_theme_constant_override("v_separation", 8) + question_toolbar.alignment = FlowContainer.ALIGNMENT_END theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) @@ -241,18 +295,44 @@ func _ready() -> void: Style.style_chip(app_feedback_button, Style.CYAN) app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) question_toolbar.add_child(app_feedback_button) + question_toolbar.move_child(labs_button, 0) + question_toolbar.move_child(theme_button, question_toolbar.get_child_count() - 2) - top.add_child(question_toolbar) - add_child(top) + header_toolbar_host = HBoxContainer.new() + header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END + header_toolbar_host.add_child(question_toolbar) + header_row.add_child(header_toolbar_host) - nav_palette = HBoxContainer.new() - nav_palette.add_theme_constant_override("separation", 6) - add_child(nav_palette) + navigator_popup = PopupPanel.new() + navigator_popup.name = "QuestionNavigator" + navigator_popup.min_size = Vector2i(340, 180) + add_child(navigator_popup) + var navigator_margin := MarginContainer.new() + navigator_margin.add_theme_constant_override("margin_left", 14) + navigator_margin.add_theme_constant_override("margin_right", 14) + navigator_margin.add_theme_constant_override("margin_top", 14) + navigator_margin.add_theme_constant_override("margin_bottom", 14) + navigator_popup.add_child(navigator_margin) + var navigator_content := VBoxContainer.new() + navigator_content.add_theme_constant_override("separation", 10) + navigator_margin.add_child(navigator_content) + var navigator_heading := Label.new() + navigator_heading.name = "NavigatorHeading" + navigator_heading.text = "Questions" + _set_scaled_label(navigator_heading, 16, Brand.DARK_TEXT) + navigator_content.add_child(navigator_heading) + var navigator_scroll := ScrollContainer.new() + navigator_scroll.custom_minimum_size = Vector2(310, 120) + navigator_content.add_child(navigator_scroll) + nav_palette = GridContainer.new() + nav_palette.columns = 8 + nav_palette.add_theme_constant_override("h_separation", 6) + nav_palette.add_theme_constant_override("v_separation", 6) + navigator_scroll.add_child(nav_palette) - var question_panel := PanelContainer.new() - question_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("182235"), Color("344463"))) + question_panel = PanelContainer.new() question_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL - add_child(question_panel) + card_content.add_child(question_panel) question_scroll = ScrollContainer.new() question_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -261,7 +341,8 @@ func _ready() -> void: var content_margin := MarginContainer.new() content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - content_margin.add_theme_constant_override("margin_right", 16) + content_margin.add_theme_constant_override("margin_left", 4) + content_margin.add_theme_constant_override("margin_right", 12) question_scroll.add_child(content_margin) question_content = VBoxContainer.new() @@ -301,8 +382,13 @@ func _ready() -> void: question_content.add_child(question_elo_label) for index in range(5): + var choice_row := VBoxContainer.new() + choice_row.add_theme_constant_override("separation", 6) + question_content.add_child(choice_row) + choice_rows.append(choice_row) + var button := Button.new() - button.custom_minimum_size = Vector2(0, 64) + button.custom_minimum_size = Vector2(0, 54) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) UISoundService.set_button_sound(button, "select") @@ -311,6 +397,22 @@ func _ready() -> void: button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i)) choice_buttons.append(button) + var badge := Label.new() + badge.mouse_filter = Control.MOUSE_FILTER_IGNORE + badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + badge.anchor_left = 0.0 + badge.anchor_top = 0.0 + badge.anchor_right = 0.0 + badge.anchor_bottom = 0.0 + badge.offset_left = 12 + badge.offset_top = 12 + badge.offset_right = 42 + badge.offset_bottom = 42 + _set_scaled_label(badge, 14, Brand.muted_color(true)) + button.add_child(badge) + choice_badges.append(badge) + var percent_label := Label.new() percent_label.visible = false percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -320,7 +422,7 @@ func _ready() -> void: percent_label.anchor_top = 0.0 percent_label.anchor_right = 1.0 percent_label.anchor_bottom = 0.0 - percent_label.offset_left = -92 + percent_label.offset_left = -82 percent_label.offset_top = 8 percent_label.offset_right = -18 percent_label.offset_bottom = 40 @@ -334,51 +436,35 @@ func _ready() -> void: top_label.bbcode_enabled = true top_label.fit_content = false top_label.scroll_active = false - top_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - top_label.offset_left = 14 + top_label.anchor_left = 0.0 + top_label.anchor_top = 0.0 + top_label.anchor_right = 1.0 + top_label.anchor_bottom = 0.0 + top_label.offset_left = 54 top_label.offset_top = 10 top_label.offset_right = -96 - top_label.offset_bottom = -252 + top_label.offset_bottom = 42 _set_scaled_rich_text(top_label, 16) top_label.add_theme_color_override("default_color", Style.RED) button.add_child(top_label) choice_top_labels.append(top_label) + choice_row.add_child(button) + var followup_panel := PanelContainer.new() followup_panel.visible = false followup_panel.mouse_filter = Control.MOUSE_FILTER_STOP - followup_panel.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - followup_panel.offset_left = 16 - followup_panel.offset_top = 48 - followup_panel.offset_right = -16 - followup_panel.offset_bottom = -16 - followup_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("26151a"), Style.RED)) - button.add_child(followup_panel) + choice_row.add_child(followup_panel) choice_followup_panels.append(followup_panel) var followup_content := VBoxContainer.new() - followup_content.add_theme_constant_override("separation", 6) + followup_content.add_theme_constant_override("separation", 8) followup_panel.add_child(followup_content) choice_followup_contents.append(followup_content) - question_content.add_child(button) + _build_answer_review_ui() - explanation_label = RichTextLabel.new() - explanation_label.bbcode_enabled = true - explanation_label.fit_content = true - explanation_label.selection_enabled = true - explanation_label.scroll_active = false - explanation_label.custom_minimum_size = Vector2.ZERO - explanation_label.visible = false - _set_scaled_rich_text(explanation_label, 17) - question_content.add_child(explanation_label) - - references_container = VBoxContainer.new() - references_container.visible = false - references_container.add_theme_constant_override("separation", 6) - question_content.add_child(references_container) - - var actions := HBoxContainer.new() + actions = HBoxContainer.new() actions.add_theme_constant_override("separation", 10) skip_button = Button.new() skip_button.text = "Skip" @@ -396,18 +482,22 @@ func _ready() -> void: Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) - add_child(actions) + card_content.add_child(actions) + timer_footer = HBoxContainer.new() + timer_footer.add_theme_constant_override("separation", 12) + card_content.add_child(timer_footer) timer_bar = ProgressBar.new() timer_bar.show_percentage = false timer_bar.min_value = 0.0 timer_bar.max_value = 100.0 timer_bar.value = 100.0 - timer_bar.custom_minimum_size = Vector2(0, 14) + timer_bar.custom_minimum_size = Vector2(0, 7) timer_bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53"))) timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME)) - add_child(timer_bar) + timer_footer.add_child(timer_bar) + timer_footer.add_child(timer_label) feedback_fog = ColorRect.new() feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) @@ -420,7 +510,80 @@ func _ready() -> void: _build_report_modal() _build_media_viewer() resized.connect(_fit_modal_panels) + resized.connect(_refresh_responsive_layout) _fit_modal_panels() + _refresh_responsive_layout() + _apply_question_theme() + + +func _build_answer_review_ui() -> void: + correct_review_panel = PanelContainer.new() + correct_review_panel.visible = false + question_content.add_child(correct_review_panel) + var review_margin := MarginContainer.new() + review_margin.add_theme_constant_override("margin_left", 14) + review_margin.add_theme_constant_override("margin_right", 14) + review_margin.add_theme_constant_override("margin_top", 12) + review_margin.add_theme_constant_override("margin_bottom", 12) + correct_review_panel.add_child(review_margin) + var review_content := VBoxContainer.new() + review_content.add_theme_constant_override("separation", 10) + review_margin.add_child(review_content) + + correct_answer_heading = Label.new() + _set_scaled_label(correct_answer_heading, 17, Brand.SUCCESS) + review_content.add_child(correct_answer_heading) + + explanation_label = RichTextLabel.new() + explanation_label.bbcode_enabled = true + explanation_label.fit_content = true + explanation_label.selection_enabled = true + explanation_label.scroll_active = false + explanation_label.custom_minimum_size = Vector2.ZERO + _set_scaled_rich_text(explanation_label, 17) + review_content.add_child(explanation_label) + + teaching_cards = HFlowContainer.new() + teaching_cards.add_theme_constant_override("h_separation", 10) + teaching_cards.add_theme_constant_override("v_separation", 10) + review_content.add_child(teaching_cards) + + references_container = VBoxContainer.new() + references_container.visible = false + references_container.add_theme_constant_override("separation", 6) + review_content.add_child(references_container) + + review_followups_section = VBoxContainer.new() + review_followups_section.visible = false + review_followups_section.add_theme_constant_override("separation", 8) + question_content.add_child(review_followups_section) + var followup_heading := Label.new() + followup_heading.text = "FOLLOW-UP QUESTIONS" + followup_heading.name = "ReviewFollowupHeading" + _set_scaled_label(followup_heading, 15, Brand.PURPLE) + review_followups_section.add_child(followup_heading) + var followup_subtitle := Label.new() + followup_subtitle.text = "Answer to reinforce key concepts. These responses do not affect your score." + followup_subtitle.name = "ReviewFollowupSubtitle" + followup_subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(followup_subtitle, 14, Brand.SLATE) + review_followups_section.add_child(followup_subtitle) + review_followups_content = VBoxContainer.new() + review_followups_content.add_theme_constant_override("separation", 8) + review_followups_section.add_child(review_followups_content) + + wrong_options_section = VBoxContainer.new() + wrong_options_section.visible = false + wrong_options_section.add_theme_constant_override("separation", 8) + question_content.add_child(wrong_options_section) + var wrong_options_heading := Label.new() + wrong_options_heading.text = "WHY OTHER OPTIONS ARE INCORRECT" + wrong_options_heading.name = "WrongOptionsHeading" + _set_scaled_label(wrong_options_heading, 15, Brand.text_color(true)) + wrong_options_section.add_child(wrong_options_heading) + wrong_options_content = VBoxContainer.new() + wrong_options_content.add_theme_constant_override("separation", 6) + wrong_options_section.add_child(wrong_options_content) func _fit_modal_panels() -> void: @@ -435,6 +598,240 @@ func _fit_modal_panels() -> void: minf(430.0, maxf(360.0, available.y))) +func _refresh_responsive_layout() -> void: + if header_toolbar_host == null or header_row == null or header_stack == null: + return + var compact := size.x < 1120.0 + var target_parent: Control = header_row + if compact: + target_parent = header_stack + if header_toolbar_host.get_parent() != target_parent: + header_toolbar_host.reparent(target_parent) + header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END + question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 520.0 + player_stats_label.visible = size.x >= 720.0 + app_feedback_button.text = "" if size.x < 760.0 else "Feedback" + app_feedback_button.custom_minimum_size.x = 44 if size.x < 760.0 else 92 + if size.x < 560.0: + nav_palette.columns = 5 + else: + nav_palette.columns = 8 + + +func _question_card_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081326") if dark else Color("ffffff") + box.border_color = Color("253555") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(8) + return box + + +func _question_content_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081326") if dark else Color("ffffff") + box.set_corner_radius_all(6) + return box + + +func _answer_box(dark: bool, state: String) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + var background := Color("0b172b") if dark else Color("ffffff") + var border := Color("2b3958") if dark else Brand.LIGHT_BORDER + if state == "selected": + background = Color("17183b") if dark else Color("f7f3ff") + border = Brand.PURPLE + elif state == "correct": + background = Color("08251d") if dark else Color("ecfdf3") + border = Brand.SUCCESS + elif state == "wrong": + background = Color("32131d") if dark else Color("fff1f2") + border = Color("fb7185") + elif state == "eliminated": + background = Color("0a1323") if dark else Color("f8fafc") + border = Color("26344f") if dark else Color("e2e8f0") + box.bg_color = background + box.border_color = border + box.set_border_width_all(1) + box.set_corner_radius_all(7) + box.content_margin_left = 54 + box.content_margin_right = 82 + box.content_margin_top = 12 + box.content_margin_bottom = 12 + return box + + +func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("13203a") if dark else Color("f8fafc") + box.border_color = Brand.PURPLE if active else (Color("33415d") if dark else Brand.LIGHT_BORDER) + box.set_border_width_all(1) + box.set_corner_radius_all(7) + return box + + +func _primary_action_box(dark: bool, state: String) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + if state == "disabled": + box.bg_color = Color("1b2539") if dark else Color("dbe3ef") + box.border_color = Color("2b3952") if dark else Color("cbd5e1") + elif state == "hover": + box.bg_color = Brand.PURPLE + box.border_color = Color("a78bfa") + elif state == "pressed": + box.bg_color = Color("6d28d9") + box.border_color = Brand.PURPLE + else: + box.bg_color = Color("4f5ff7") + box.border_color = Brand.PURPLE + box.set_border_width_all(1) + box.set_corner_radius_all(7) + box.content_margin_left = 14 + box.content_margin_right = 14 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _correct_review_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("08251d") if dark else Color("f0fdf4") + box.border_color = Color(Brand.SUCCESS, 0.62) + box.set_border_width_all(1) + box.set_corner_radius_all(7) + return box + + +func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color(accent, 0.08 if dark else 0.045) + box.border_color = Color(accent, 0.42) + box.set_border_width_all(1) + box.set_corner_radius_all(6) + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _review_followup_box(dark: bool, accent: Color, answered: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color(accent, 0.075 if answered else 0.04) + box.border_color = Color(accent, 0.5) + box.set_border_width_all(1) + box.set_corner_radius_all(7) + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _wrong_option_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("0b172b") if dark else Color("ffffff") + box.border_color = Color("2b3958") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(6) + box.content_margin_left = 10 + box.content_margin_right = 10 + box.content_margin_top = 6 + box.content_margin_bottom = 6 + return box + + +func _style_review_followup_option(button: Button, dark: bool, option_id: String, selected_id: String, correct_id: String, answered: bool) -> void: + var state := "default" + if answered and option_id == correct_id: + state = "correct" + elif answered and option_id == selected_id: + state = "wrong" + var box := _answer_box(dark, state) + box.content_margin_left = 12 + box.content_margin_right = 12 + var hover_box := _answer_box(dark, "selected" if state == "default" else state) + hover_box.content_margin_left = 12 + hover_box.content_margin_right = 12 + button.add_theme_stylebox_override("normal", box) + button.add_theme_stylebox_override("disabled", box) + button.add_theme_stylebox_override("hover", hover_box) + button.add_theme_stylebox_override("pressed", hover_box) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) + + +func _apply_question_theme() -> void: + if theme_service == null or question_card == null or question_panel == null: + return + var dark: bool = theme_service.is_dark_mode() + question_card.add_theme_stylebox_override("panel", _question_card_box(dark)) + question_panel.add_theme_stylebox_override("panel", _question_content_box(dark)) + if navigator_popup != null: + navigator_popup.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + var step_badge := header_row.get_node_or_null("StepBadge") as Label + if step_badge != null: + step_badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, Brand.PURPLE)) + step_badge.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("c4b5fd")) + progress_button.add_theme_color_override("font_color", Brand.text_color(dark)) + progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE) + progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) + player_stats_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + timer_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + stem_label.add_theme_color_override("default_color", Brand.text_color(dark)) + lead_label.add_theme_color_override("font_color", Brand.text_color(dark)) + explanation_label.add_theme_color_override("default_color", Brand.text_color(dark)) + var review_followup_heading := find_child("ReviewFollowupHeading", true, false) as Label + if review_followup_heading != null: + review_followup_heading.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("8b7cff")) + var review_followup_subtitle := find_child("ReviewFollowupSubtitle", true, false) as Label + if review_followup_subtitle != null: + review_followup_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark)) + var wrong_options_heading := find_child("WrongOptionsHeading", true, false) as Label + if wrong_options_heading != null: + wrong_options_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + if correct_review_panel != null: + correct_review_panel.add_theme_stylebox_override("panel", _correct_review_box(dark)) + question_elo_label.add_theme_color_override("font_color", Brand.AMBER) + var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label + if navigator_heading != null: + navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) + for control in [theme_button, text_smaller_button, text_larger_button, labs_button, flag_button, report_button, app_feedback_button]: + if control == null: + continue + var active: bool = control == flag_button and flagged + control.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) + control.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) + control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) + control.add_theme_color_override("font_color", Brand.text_color(dark)) + control.add_theme_color_override("icon_normal_color", Brand.SKY_BLUE) + if skip_button != null: + skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark)) + skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) + skip_button.add_theme_color_override("font_color", Brand.text_color(dark)) + if next_button != null: + next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal")) + next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover")) + next_button.add_theme_stylebox_override("pressed", _primary_action_box(dark, "pressed")) + next_button.add_theme_stylebox_override("disabled", _primary_action_box(dark, "disabled")) + next_button.add_theme_color_override("font_color", Color.WHITE) + next_button.add_theme_color_override("font_hover_color", Color.WHITE) + next_button.add_theme_color_override("font_pressed_color", Color.WHITE) + next_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark)) + for index in range(choice_buttons.size()): + var choice_id := "" + if index < presented_choices.size(): + choice_id = str(presented_choices[index].get("id", "")) + _style_choice_button_for_state(index, choice_id) + for attempted_choice_id in followup_attempt_order: + _render_followup_state(str(attempted_choice_id)) + if correct_review_panel != null and correct_review_panel.visible: + _render_teaching_cards() + _render_review_followups() + _render_wrong_option_reviews() + + func on_show(_payload: Variant = null) -> void: _load_question() @@ -491,7 +888,8 @@ func _process(delta: float) -> void: if visible and BlockSessionService.session_kind == "simulation": BlockSessionService.seconds_remaining = maxf(0.0, ExamSimulationService.get_block_deadline_unix() - Time.get_unix_time_from_system()) var simulation_seconds := int(ceil(BlockSessionService.seconds_remaining)) - timer_label.text = "Block time: %02d:%02d" % [int(simulation_seconds / 60.0), simulation_seconds % 60] + _set_timer_display(simulation_seconds) + _update_timer_bar() if simulation_seconds <= 0 and not simulation_submit_pending: simulation_submit_pending = true ExamSimulationService.submit_block() @@ -501,8 +899,8 @@ func _process(delta: float) -> void: if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible): BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) - var minutes := int(floor(float(shown_seconds) / 60.0)) - timer_label.text = "Timed: %02d:%02d" % [minutes, shown_seconds % 60] + _set_timer_display(shown_seconds) + _update_timer_bar() if BlockSessionService.seconds_remaining <= 0: _finish() @@ -565,25 +963,33 @@ func _load_question() -> void: current_answer = {} flag_button.button_pressed = false _refresh_toolbar_state() - explanation_label.visible = false + _clear_answer_review() _clear_references() question_elo_label.visible = false question_elo_label.text = "" next_button.text = "Submit" next_button.disabled = true skip_button.disabled = false - skip_button.visible = BlockSessionService.session_kind == "core" + skip_button.visible = true report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() if question.is_empty(): main.show_screen("selection", "step3") return - progress_label.visible = BlockSessionService.session_kind != "core" - progress_label.text = "" if BlockSessionService.session_kind == "core" else BlockSessionService.get_progress_text() - timer_label.visible = BlockSessionService.session_kind != "core" - timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" - timer_bar.visible = BlockSessionService.session_kind == "core" + progress_button.text = _question_progress_text() + progress_button.disabled = BlockSessionService.session_kind == "core" + progress_button.tooltip_text = "" if BlockSessionService.session_kind == "core" else "Open question navigator" + var show_timer := _is_session_timer_visible() + timer_label.visible = show_timer + timer_bar.visible = show_timer + timer_footer.visible = show_timer + if show_timer: + if BlockSessionService.session_kind == "core": + var core_seconds := int(ceil(BlockSessionService.core_current_target_seconds - BlockSessionService.get_current_elapsed_seconds())) + _set_timer_display(maxi(0, core_seconds)) + else: + _set_timer_display(int(ceil(BlockSessionService.seconds_remaining))) _update_timer_bar() stem_plain_text = str(question.get("stem", "")) explanation_plain_text = str(question.get("explanation", "")) @@ -597,6 +1003,8 @@ func _load_question() -> void: for index in range(choice_buttons.size()): var button := choice_buttons[index] button.visible = index < choices.size() + if index < choice_rows.size(): + choice_rows[index].visible = index < choices.size() button.disabled = false if index < choices.size(): var choice: Dictionary = choices[index] @@ -623,6 +1031,7 @@ func _load_question() -> void: _refresh_nav_palette() _refresh_toolbar_state() _refresh_player_stats() + _apply_question_theme() func _select_choice(index: int) -> void: @@ -696,7 +1105,21 @@ func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> b func _skip_current_question() -> void: - if pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core": + if pending_correct_advance or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation"): + return + if BlockSessionService.session_kind != "core": + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() + var target_index := _next_revisitable_index() + if target_index >= 0 and BlockSessionService.move_to(target_index): + _load_question() + elif BlockSessionService.session_kind == "simulation" and not simulation_submit_pending: + simulation_submit_pending = true + next_button.disabled = true + next_button.text = "Submitting..." + ExamSimulationService.submit_block() + else: + _finish() return var question_id := str(BlockSessionService.get_current_question().get("id", "")) if question_id == "": @@ -714,6 +1137,18 @@ func _skip_current_question() -> void: _update_question_elo_label(true) +func _next_revisitable_index() -> int: + var question_count := BlockSessionService.active_questions.size() + if question_count <= 1: + return -1 + for offset in range(1, question_count): + var candidate := (BlockSessionService.current_index + offset) % question_count + var answer = BlockSessionService.get_answer_for_index(candidate) + if answer == null or str(answer.get("selected_choice_id", "")) == "": + return candidate + return (BlockSessionService.current_index + 1) % question_count + + func _choice_gui_input(event: InputEvent, index: int) -> void: if pending_correct_advance or followup_required or _is_correct_answer_locked(): return @@ -860,7 +1295,11 @@ func _on_random_question_failed(message: String) -> void: func _show_answer_review() -> void: _show_explanation() _show_references(BlockSessionService.get_current_question().get("references", [])) + _ensure_answer_review_followups() + _render_answer_review_sections() timer_bar.visible = false + timer_label.visible = false + timer_footer.visible = false next_button.text = "Next" next_button.disabled = false if skip_button != null: @@ -872,7 +1311,6 @@ func _show_answer_review() -> void: func _show_explanation() -> void: var question := BlockSessionService.get_current_question() - var correct_id := str(question.get("correct_choice_id", "")) for button in choice_buttons: button.disabled = true var choices := _get_presented_choices() @@ -880,18 +1318,225 @@ func _show_explanation() -> void: var choice: Dictionary = choices[index] var id := str(choice.get("id", "")) _set_choice_button_text(index, choice) - if id == correct_id: - choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) - elif id == selected_choice: - choice_buttons[index].modulate = Color(1.0, 0.48, 0.48) + _style_choice_button_for_state(index, id) explanation_plain_text = str(question.get("explanation", "")) _render_explanation() explanation_label.visible = true + correct_review_panel.visible = true + var correct_id := str(question.get("correct_choice_id", "")) + correct_answer_heading.text = "Correct Answer: %s" % _display_label_for_choice_id(correct_id) _update_question_elo_label(true) _refresh_nav_palette() +func _render_answer_review_sections() -> void: + _render_teaching_cards() + _render_review_followups() + _render_wrong_option_reviews() + + +func _clear_answer_review() -> void: + if explanation_label != null: + explanation_label.visible = false + if correct_review_panel != null: + correct_review_panel.visible = false + _clear_container_children(teaching_cards) + _clear_container_children(review_followups_content) + _clear_container_children(wrong_options_content) + review_followup_states.clear() + wrong_option_expanded.clear() + if review_followups_section != null: + review_followups_section.visible = false + if wrong_options_section != null: + wrong_options_section.visible = false + + +func _clear_container_children(container: Control) -> void: + if container == null: + return + for child in container.get_children(): + container.remove_child(child) + child.queue_free() + + +func _render_teaching_cards() -> void: + if teaching_cards == null: + return + _clear_container_children(teaching_cards) + var question := BlockSessionService.get_current_question() + var takeaway := str(question.get("key_takeaway", "")).strip_edges() + if takeaway == "": + var correct_id := str(question.get("correct_choice_id", "")) + var explanations: Dictionary = question.get("choice_explanations", {}) + takeaway = _strip_correct_prefix(str(explanations.get(correct_id, "")).strip_edges()) + var pearl := str(question.get("usmle_pearl", "")).strip_edges() + if takeaway != "": + _add_teaching_card("KEY TAKEAWAY", takeaway, Brand.SKY_BLUE) + if pearl != "": + _add_teaching_card("USMLE PEARL", pearl, Brand.PURPLE) + teaching_cards.visible = teaching_cards.get_child_count() > 0 + + +func _add_teaching_card(title_text: String, body_text: String, accent: Color) -> void: + var dark := _followup_is_dark() + var card := PanelContainer.new() + card.custom_minimum_size = Vector2(360, 0) + card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + card.add_theme_stylebox_override("panel", _teaching_card_box(dark, accent)) + teaching_cards.add_child(card) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 6) + card.add_child(content) + var heading := Label.new() + heading.text = title_text + _set_scaled_label(heading, 13, accent) + content.add_child(heading) + var body := Label.new() + body.text = body_text + body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(body, 14, Brand.text_color(dark)) + content.add_child(body) + + +func _render_review_followups() -> void: + if review_followups_content == null: + return + _clear_container_children(review_followups_content) + var question := BlockSessionService.get_current_question() + var authored: Array = question.get("review_followups", []) + if review_followup_states.size() != authored.size(): + review_followup_states.clear() + for _index in range(authored.size()): + review_followup_states.append({"selected_choice_id": "", "answered": false}) + review_followups_section.visible = not authored.is_empty() + if authored.is_empty(): + return + var answered_count := 0 + for state in review_followup_states: + if bool(state.get("answered", false)): + answered_count += 1 + var heading := find_child("ReviewFollowupHeading", true, false) as Label + if heading != null: + heading.text = "FOLLOW-UP QUESTIONS | %d of %d answered" % [answered_count, authored.size()] + for index in range(authored.size()): + if typeof(authored[index]) != TYPE_DICTIONARY: + continue + _add_review_followup_card(index, authored[index], review_followup_states[index]) + + +func _add_review_followup_card(index: int, followup: Dictionary, state: Dictionary) -> void: + var dark := _followup_is_dark() + var answered := bool(state.get("answered", false)) + var selected_id := str(state.get("selected_choice_id", "")) + var correct_id := str(followup.get("correct_choice_id", "")) + var is_correct := answered and selected_id == correct_id + var accent := Brand.SUCCESS if is_correct else Color("fb7185") if answered else Brand.PURPLE + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", _review_followup_box(dark, accent, answered)) + review_followups_content.add_child(card) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 8) + card.add_child(content) + var prompt := Label.new() + prompt.text = "%d. %s" % [index + 1, str(followup.get("prompt", ""))] + prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(prompt, 15, Brand.text_color(dark)) + content.add_child(prompt) + var choices: Array = followup.get("choices", []) + for option in choices: + if typeof(option) != TYPE_DICTIONARY: + continue + var option_id := str(option.get("id", "")) + var option_button := Button.new() + option_button.text = "%s. %s" % [option_id, str(option.get("text", ""))] + option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + option_button.disabled = answered + option_button.custom_minimum_size = Vector2(0, 38) + _set_scaled_button_font(option_button, 14) + _style_review_followup_option(option_button, dark, option_id, selected_id, correct_id, answered) + option_button.pressed.connect(_answer_review_followup.bind(index, option_id)) + content.add_child(option_button) + if answered: + var teaching_point := Label.new() + teaching_point.text = str(followup.get("teaching_point", "")) + teaching_point.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(teaching_point, 14, accent) + content.add_child(teaching_point) + + +func _answer_review_followup(index: int, choice_id: String) -> void: + if index < 0 or index >= review_followup_states.size(): + return + var state: Dictionary = review_followup_states[index] + if bool(state.get("answered", false)): + return + state["selected_choice_id"] = choice_id + state["answered"] = true + review_followup_states[index] = state + _render_review_followups() + + +func _render_wrong_option_reviews() -> void: + if wrong_options_content == null: + return + _clear_container_children(wrong_options_content) + var question := BlockSessionService.get_current_question() + var correct_id := str(question.get("correct_choice_id", "")) + var explanations: Dictionary = question.get("choice_explanations", {}) + var incorrect_count := 0 + for choice in _get_presented_choices(): + if typeof(choice) != TYPE_DICTIONARY: + continue + var choice_id := str(choice.get("id", "")) + if choice_id == correct_id: + continue + incorrect_count += 1 + _add_wrong_option_review(choice, str(explanations.get(choice_id, ""))) + wrong_options_section.visible = incorrect_count > 0 + + +func _add_wrong_option_review(choice: Dictionary, explanation: String) -> void: + var dark := _followup_is_dark() + var choice_id := str(choice.get("id", "")) + var expanded := bool(wrong_option_expanded.get(choice_id, false)) + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", _wrong_option_box(dark)) + wrong_options_content.add_child(card) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 6) + card.add_child(content) + var header := Button.new() + header.flat = true + header.alignment = HORIZONTAL_ALIGNMENT_LEFT + header.text = "%s. %s" % [_display_label_for_choice_id(choice_id), str(choice.get("text", ""))] + header.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg") + header.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT + header.expand_icon = false + header.add_theme_color_override("font_color", Brand.text_color(dark)) + header.add_theme_color_override("icon_normal_color", Brand.muted_color(dark)) + _set_scaled_button_font(header, 14) + header.pressed.connect(_toggle_wrong_option_review.bind(choice_id)) + content.add_child(header) + if expanded: + var detail := Label.new() + detail.text = explanation + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(detail, 14, Brand.muted_color(dark)) + content.add_child(detail) + + +func _toggle_wrong_option_review(choice_id: String) -> void: + wrong_option_expanded[choice_id] = not bool(wrong_option_expanded.get(choice_id, false)) + _render_wrong_option_reviews() + + +func _strip_correct_prefix(value: String) -> String: + if value.begins_with("Correct. "): + return value.substr(9) + return value + + func _show_references(raw_references: Variant) -> void: _clear_references() if typeof(raw_references) != TYPE_ARRAY: @@ -973,10 +1618,13 @@ func _exit_run() -> void: func _update_timer_bar() -> void: if timer_bar == null: return - var ratio := BlockSessionService.get_core_timer_ratio() + var ratio := BlockSessionService.get_session_timer_ratio() timer_bar.value = ratio * 100.0 - var color_state := "green" - var fill_color := Style.LIME + if BlockSessionService.session_kind == "core" and timer_label.visible: + var remaining := int(ceil(BlockSessionService.core_current_target_seconds - BlockSessionService.get_current_elapsed_seconds())) + _set_timer_display(maxi(0, remaining)) + var color_state := "blue" + var fill_color := Brand.ROYAL_BLUE if ratio < 0.25: color_state = "red" fill_color = Style.RED @@ -988,6 +1636,54 @@ func _update_timer_bar() -> void: timer_bar.add_theme_stylebox_override("fill", Style.button_box(fill_color, fill_color)) +func _set_timer_display(total_seconds: int) -> void: + if timer_label == null: + return + var safe_seconds := maxi(0, total_seconds) + timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60] + + +func _is_session_timer_visible() -> bool: + if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation": + return true + return BlockSessionService.mode == "Timed" + + +func _question_progress_text() -> String: + if BlockSessionService.session_kind == "core": + return "Question %d" % (BlockSessionService.core_answer_count + 1) + return "Question %d of %d" % [ + mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()), + BlockSessionService.active_questions.size() + ] + + +func _toggle_question_navigator() -> void: + if navigator_popup == null or BlockSessionService.session_kind == "core": + return + if navigator_popup.visible: + navigator_popup.hide() + return + _refresh_nav_palette() + var popup_size := Vector2i(340, 200) + var anchor := progress_button.get_global_rect() + var viewport_size := get_viewport_rect().size + var popup_x := int(clampf(anchor.position.x, 8.0, maxf(8.0, viewport_size.x - popup_size.x - 8.0))) + var popup_y := int(minf(anchor.end.y + 6.0, viewport_size.y - popup_size.y - 8.0)) + navigator_popup.popup(Rect2i(Vector2i(popup_x, popup_y), popup_size)) + + +func _navigate_to_question(target_index: int) -> void: + if target_index == BlockSessionService.current_index: + navigator_popup.hide() + return + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() + if BlockSessionService.move_to(target_index): + navigator_popup.hide() + _load_question() + + func _lock_wrong_choice(choice_id: String) -> void: if choice_id == "": return @@ -1125,12 +1821,10 @@ func _toggle_report_current_question() -> void: func _refresh_toolbar_state() -> void: _refresh_theme_button() _refresh_labs_button() - if flag_button != null: - Style.style_chip(flag_button, Style.AMBER if flagged else Style.CYAN) if report_button != null: var question_id := str(BlockSessionService.get_current_question().get("id", "")) - Style.style_chip(report_button, Style.RED if reported_question_ids.has(question_id) else Style.CYAN) report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" + _apply_question_theme() func refresh_theme_controls() -> void: @@ -1149,6 +1843,7 @@ func refresh_theme_controls() -> void: child.icon = _make_clear_highlights_icon() _refresh_toolbar_state() _apply_saved_choice_state() + _apply_question_theme() func _refresh_theme_button() -> void: @@ -1162,7 +1857,7 @@ func _refresh_theme_button() -> void: func _refresh_player_stats() -> void: if player_stats_label == null: return - var display_name := AuthState.username if AuthState.username != "" else AuthState.role + var display_name := AuthState.role.to_upper() if AuthState.role != "" else AuthState.username player_stats_label.text = "Rating: %d | %s" % [ QuestionBankService.overall_elo, display_name @@ -1658,9 +2353,9 @@ func _build_report_modal() -> void: _set_scaled_label(report_error_label, 15, Style.RED) report_form_content.add_child(report_error_label) - var actions := HBoxContainer.new() - actions.add_theme_constant_override("separation", 10) - report_form_content.add_child(actions) + var report_actions := HBoxContainer.new() + report_actions.add_theme_constant_override("separation", 10) + report_form_content.add_child(report_actions) report_cancel_button = Button.new() report_cancel_button.text = "Cancel" @@ -1668,7 +2363,7 @@ func _build_report_modal() -> void: Style.style_chip(report_cancel_button, Style.MUTED) UISoundService.set_button_sound(report_cancel_button, "close") report_cancel_button.pressed.connect(_hide_report_modal) - actions.add_child(report_cancel_button) + report_actions.add_child(report_cancel_button) report_submit_button = Button.new() report_submit_button.text = "Submit report" @@ -1677,7 +2372,7 @@ func _build_report_modal() -> void: Style.style_primary_button(report_submit_button) UISoundService.set_button_sound(report_submit_button, "confirmation") report_submit_button.pressed.connect(_submit_question_report) - actions.add_child(report_submit_button) + report_actions.add_child(report_submit_button) report_success_content = VBoxContainer.new() report_success_content.visible = false @@ -2011,18 +2706,10 @@ func _apply_saved_choice_state() -> void: 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 = answer_is_locked or (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) _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 - elif eliminated.has(id): - choice_buttons[index].modulate = Color(0.45, 0.45, 0.45) - elif id == selected_choice: - choice_buttons[index].modulate = Color(0.68, 0.92, 1.0) - else: - choice_buttons[index].modulate = Color.WHITE + choice_buttons[index].modulate = Color.WHITE if pending_correct_advance or bool(current_answer.get("is_correct", false)): _apply_correct_answer_preview() @@ -2058,52 +2745,60 @@ func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) var display_label := _display_label_for_index(index) - var has_visible_followup := index < choice_followup_panels.size() and choice_followup_panels[index].visible - choice_top_labels[index].offset_bottom = -252 if has_visible_followup else -8 - choice_buttons[index].custom_minimum_size = _choice_row_size(index, choice_id, has_visible_followup) + if index < choice_badges.size(): + choice_badges[index].text = display_label + choice_buttons[index].custom_minimum_size = Vector2(0, 54) _set_choice_percent_label(index, "") _set_choice_top_label(index, "") if locked_wrong_choice_ids.has(choice_id): var percentage := _get_choice_selection_percentage(choice_id) if percentage >= 0: _set_choice_percent_label(index, "%d%%" % percentage) - if has_visible_followup: - var wrong_text := "%s. %s" % [display_label, choice_text] - choice_buttons[index].text = "" - _set_choice_top_label(index, _escape_bbcode(wrong_text)) - else: - var wrong_text := "%s. %s" % [display_label, choice_text] - choice_buttons[index].text = "" - _set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(wrong_text)) + choice_buttons[index].text = choice_text elif _should_show_correct_choice_percentage(choice_id): var correct_percentage := _get_choice_selection_percentage(choice_id) if correct_percentage >= 0: _set_choice_percent_label(index, "%d%%" % correct_percentage) - choice_buttons[index].text = "%s. %s" % [display_label, choice_text] + choice_buttons[index].text = choice_text elif eliminated.has(choice_id): choice_buttons[index].text = "" - _set_choice_top_label(index, "[s]%s. %s[/s]" % [display_label, _escape_bbcode(choice_text)]) + _set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(choice_text)) else: - choice_buttons[index].text = "%s. %s" % [display_label, choice_text] + choice_buttons[index].text = choice_text func _style_choice_button_for_state(index: int, choice_id: String) -> void: - if choice_id == selected_choice: - choice_buttons[index].add_theme_stylebox_override("normal", Style.button_box(Color("18465a"), Style.CYAN)) - choice_buttons[index].add_theme_stylebox_override("hover", Style.button_box(Color("20647e"), Style.CYAN)) - choice_buttons[index].add_theme_stylebox_override("pressed", Style.button_box(Color("18465a"), Style.CYAN)) - else: - Style.style_answer_button(choice_buttons[index]) - - -func _choice_row_size(_index: int, choice_id: String, has_visible_followup: bool) -> Vector2: - if not has_visible_followup and choice_id != active_followup_choice_id: - return Vector2(0, 64) - if followup_states.has(choice_id): - var state: Dictionary = followup_states[choice_id] - if bool(state.get("collapsed", false)): - return Vector2(0, 136) - return Vector2(0, 300) + if index < 0 or index >= choice_buttons.size(): + return + var dark: bool = true if theme_service == null else theme_service.is_dark_mode() + var state := "default" + var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", "")) + var reviewing := pending_correct_advance or not current_answer.is_empty() + if reviewing and choice_id == correct_id: + state = "correct" + elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id == selected_choice): + state = "wrong" + elif choice_id == selected_choice: + state = "selected" + elif eliminated.has(choice_id): + state = "eliminated" + var button := choice_buttons[index] + var normal_box := _answer_box(dark, state) + button.add_theme_stylebox_override("normal", normal_box) + button.add_theme_stylebox_override("disabled", normal_box) + button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state)) + button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state)) + var text_color := Brand.muted_color(dark) if state == "eliminated" else Brand.text_color(dark) + button.add_theme_color_override("font_color", text_color) + button.add_theme_color_override("font_disabled_color", text_color) + if index < choice_badges.size(): + var badge := choice_badges[index] + var accent := Brand.SLATE + if state == "selected": accent = Brand.PURPLE + elif state == "correct": accent = Brand.SUCCESS + elif state == "wrong": accent = Color("fb7185") + badge.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, state in ["selected", "correct", "wrong"])) + badge.add_theme_color_override("font_color", Color.WHITE if state in ["selected", "correct", "wrong"] else Brand.muted_color(dark)) func _mark_review_input_handled() -> void: @@ -2147,7 +2842,7 @@ func _apply_correct_answer_preview() -> void: var choices := _get_presented_choices() for index in range(choices.size()): if str(choices[index].get("id", "")) == correct_id: - choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) + _style_choice_button_for_state(index, correct_id) return @@ -2169,16 +2864,14 @@ func _show_wrong_answer_followup(choice_id: String) -> void: _unlock_after_followup() return _build_followup_panel(index, choice_id) + if question_scroll != null: + question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index]) func _build_followup_panel(index: int, choice_id: String) -> void: - _clear_followup_content(index) - var panel := choice_followup_panels[index] - var content := choice_followup_contents[index] var followup := _get_followup_for_choice(choice_id) var followup_choices: Array = followup.get("choices", []).duplicate(true) followup_choices.shuffle() - followup["choices"] = followup_choices followup_states[choice_id] = { "choice_id": choice_id, "choice_text": _choice_text_for_id(choice_id), @@ -2191,48 +2884,56 @@ func _build_followup_panel(index: int, choice_id: String) -> void: "was_correct": false, "resolved": false, "collapsed": false, - "has_authored_choices": bool(followup.get("has_authored_choices", false)) + "has_authored_choices": bool(followup.get("has_authored_choices", false)), + "is_main_correct": false } + _append_followup_attempt(choice_id) + _render_followup_state(choice_id) + + +func _append_followup_attempt(choice_id: String) -> void: + if not followup_attempt_order.has(choice_id): + followup_attempt_order.append(choice_id) + + +func _render_active_followup(index: int, state: Dictionary) -> void: + var content := choice_followup_contents[index] + _build_followup_header(content, state, true, false) + var body_content := _add_followup_body(content, false) var prompt := Label.new() - prompt.text = str(followup.get("prompt", "What finding in the stem makes this answer less appropriate?")) + prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?")) prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(prompt, 14, Style.TEXT) - content.add_child(prompt) + _set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark())) + body_content.add_child(prompt) - var choices: Array = followup_choices - if bool(followup.get("has_authored_choices", false)): - for option_index in range(choices.size()): - var option = choices[option_index] + var options: Array = state.get("choices", []) + if bool(state.get("has_authored_choices", false)): + for option_index in range(options.size()): + var option = options[option_index] if typeof(option) != TYPE_DICTIONARY: continue var option_button := Button.new() option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))] option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT - option_button.custom_minimum_size = Vector2(0, 32) - Style.style_chip(option_button, Style.CYAN) + option_button.custom_minimum_size = Vector2(0, 38) + Style.style_chip(option_button, Brand.PURPLE) UISoundService.set_button_sound(option_button, "select") - _set_scaled_button_font(option_button, 15) + _set_scaled_button_font(option_button, 14) option_button.set_meta("option_id", str(option.get("id", ""))) - option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := followup): + option_button.pressed.connect(func(option_id := str(option.get("id", "")), selected_button := option_button, followup_data := state): _answer_followup(option_id, selected_button, followup_data) ) - content.add_child(option_button) - - if not bool(followup.get("has_authored_choices", false)): - var action_row := HBoxContainer.new() - action_row.add_theme_constant_override("separation", 8) - content.add_child(action_row) - + body_content.add_child(option_button) + else: var continue_button := Button.new() continue_button.text = "Continue" - continue_button.custom_minimum_size = Vector2(130, 32) - Style.style_chip(continue_button, Style.CYAN) - _set_scaled_button_font(continue_button, 15) - continue_button.pressed.connect(func(followup_data := followup): _resolve_followup(followup_data, null, false, false)) - action_row.add_child(continue_button) - - panel.visible = true + continue_button.custom_minimum_size = Vector2(130, 38) + continue_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(continue_button, Brand.PURPLE) + _set_scaled_button_font(continue_button, 14) + continue_button.pressed.connect(func(followup_data := state): _resolve_followup(followup_data, null, false, false)) + body_content.add_child(continue_button) func _answer_followup(option_id: String, selected_button: Button, followup: Dictionary) -> void: @@ -2249,25 +2950,6 @@ func _resolve_followup(followup: Dictionary, selected_button: Button, style_sele followup_answered = true var resolved_choice_id := active_followup_choice_id var selected_option_text := selected_button.text if selected_button != null else "Continued" - var index := _choice_index_for_id(active_followup_choice_id) - if index >= 0: - for child in choice_followup_contents[index].get_children(): - if child is Button: - child.disabled = true - elif child is HBoxContainer: - for row_child in child.get_children(): - if row_child is Button: - row_child.disabled = true - - if selected_button != null and style_selected: - _style_followup_result_button(selected_button, is_correct) - - var teaching := Label.new() - teaching.text = str(followup.get("teaching_point", "Review the relevant stem finding before choosing again.")) - teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(teaching, 14, Style.AMBER) - choice_followup_contents[index].add_child(teaching) - if followup_states.has(resolved_choice_id): var state: Dictionary = followup_states[resolved_choice_id] state["selected_option_id"] = str(selected_button.get_meta("option_id", "")) if selected_button != null and selected_button.has_meta("option_id") else "" @@ -2276,21 +2958,11 @@ func _resolve_followup(followup: Dictionary, selected_button: Button, style_sele state["resolved"] = true state["collapsed"] = false followup_states[resolved_choice_id] = state + _render_followup_state(resolved_choice_id) _unlock_after_followup() -func _style_followup_result_button(button: Button, is_correct: bool) -> void: - var fill := Color("1f7a3a") if is_correct else Color("8f1d2c") - var border := Style.LIME if is_correct else Style.RED - button.add_theme_stylebox_override("normal", Style.button_box(fill, border)) - button.add_theme_stylebox_override("hover", Style.button_box(fill, border)) - button.add_theme_stylebox_override("disabled", Style.button_box(fill, border)) - button.add_theme_color_override("font_color", Style.TEXT) - button.add_theme_color_override("font_hover_color", Style.TEXT) - button.add_theme_color_override("font_disabled_color", Style.TEXT) - - func _unlock_after_followup() -> void: active_followup_choice_id = "" followup_required = false @@ -2311,9 +2983,11 @@ func _collapse_resolved_followups() -> void: func _toggle_followup_state(choice_id: String) -> void: - if not followup_states.has(choice_id): + if not followup_states.has(choice_id) or followup_required: return var state: Dictionary = followup_states[choice_id] + if not bool(state.get("resolved", false)): + return state["collapsed"] = not bool(state.get("collapsed", false)) followup_states[choice_id] = state _render_followup_state(choice_id) @@ -2328,56 +3002,201 @@ func _render_followup_state(choice_id: String) -> void: return var state: Dictionary = followup_states[choice_id] _clear_followup_content(index) - if bool(state.get("collapsed", false)): - _build_collapsed_followup(index, state) + _style_followup_card(index, state) + if not bool(state.get("resolved", false)): + _render_active_followup(index, state) + elif bool(state.get("collapsed", false)): + _build_followup_header(choice_followup_contents[index], state, false, true) else: - _build_readonly_followup(index, state) + _build_resolved_followup(index, state) choice_followup_panels[index].visible = true -func _build_collapsed_followup(index: int, state: Dictionary) -> void: +func _build_resolved_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] + _build_followup_header(content, state, true, true) + var is_main_correct := bool(state.get("is_main_correct", false)) + var body_content := _add_followup_body(content, is_main_correct) + + var heading := Label.new() + heading.text = "Why this is appropriate:" if is_main_correct else "Why this is less appropriate:" + _set_scaled_label(heading, 14, Brand.SUCCESS if is_main_correct else Color("fb5f6d")) + body_content.add_child(heading) + + var explanation := Label.new() + explanation.text = _choice_review_explanation(str(state.get("choice_id", "")), state) + explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark())) + body_content.add_child(explanation) + + if not is_main_correct and bool(state.get("has_authored_choices", false)): + var similar_button := Button.new() + similar_button.text = "Ask similar question" + similar_button.custom_minimum_size = Vector2(172, 38) + similar_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(similar_button, Color("fb5f6d")) + _set_scaled_button_font(similar_button, 14) + similar_button.pressed.connect(_restart_followup.bind(str(state.get("choice_id", "")))) + body_content.add_child(similar_button) + + +func _build_followup_header(content: VBoxContainer, state: Dictionary, expanded: bool, interactive: bool) -> void: + var dark := _followup_is_dark() + var is_main_correct := bool(state.get("is_main_correct", false)) + var accent := Brand.SUCCESS if is_main_correct else Color("fb5f6d") + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + content.add_child(row) + + var status := Label.new() + status.text = "✓" if is_main_correct else "x" + status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + status.custom_minimum_size = Vector2(30, 30) + status.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, true)) + _set_scaled_label(status, 15, Color.WHITE) + row.add_child(status) + var summary := Button.new() + summary.name = "FollowupSummary" + summary.flat = true summary.text = "Review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] summary.alignment = HORIZONTAL_ALIGNMENT_LEFT - summary.custom_minimum_size = Vector2(0, 40) - Style.style_chip(summary, Style.AMBER) - UISoundService.set_button_sound(summary, "open") + summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL + summary.disabled = not interactive _set_scaled_button_font(summary, 15) - summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) - content.add_child(summary) + summary.add_theme_color_override("font_color", Brand.text_color(dark)) + summary.add_theme_color_override("font_hover_color", accent) + summary.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) + if interactive: + summary.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", "")))) + row.add_child(summary) + + var percentage := _get_choice_selection_percentage(str(state.get("choice_id", ""))) + if percentage >= 0: + var percentage_label := Label.new() + percentage_label.text = "%d%%" % percentage + percentage_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + percentage_label.custom_minimum_size = Vector2(58, 30) + percentage_label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) + _set_scaled_label(percentage_label, 14, accent) + row.add_child(percentage_label) + + if interactive: + var toggle := Button.new() + toggle.flat = true + toggle.tooltip_text = "Collapse review" if expanded else "Expand review" + toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg") + toggle.custom_minimum_size = Vector2(34, 30) + toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark)) + toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", "")))) + row.add_child(toggle) -func _build_readonly_followup(index: int, state: Dictionary) -> void: - var content := choice_followup_contents[index] - var summary := Button.new() - summary.text = "Hide review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] - summary.alignment = HORIZONTAL_ALIGNMENT_LEFT - summary.custom_minimum_size = Vector2(0, 36) - Style.style_chip(summary, Style.AMBER) - UISoundService.set_button_sound(summary, "close") - _set_scaled_button_font(summary, 15) - summary.pressed.connect(func(choice_id := str(state.get("choice_id", ""))): _toggle_followup_state(choice_id)) - content.add_child(summary) +func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer: + var body := PanelContainer.new() + body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct)) + content.add_child(body) + var body_content := VBoxContainer.new() + body_content.add_theme_constant_override("separation", 8) + body.add_child(body_content) + return body_content - var prompt := Label.new() - prompt.text = str(state.get("prompt", "")) - prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(prompt, 14, Style.TEXT) - content.add_child(prompt) - var selected := Label.new() - var selected_text := str(state.get("selected_option_text", "")) - selected.text = "Your follow-up answer: %s" % selected_text if selected_text != "" else "Follow-up reviewed." - selected.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(selected, 14, Style.LIME if bool(state.get("was_correct", false)) else Style.RED) - content.add_child(selected) +func _style_followup_card(index: int, state: Dictionary) -> void: + if index < 0 or index >= choice_followup_panels.size(): + return + choice_followup_panels[index].add_theme_stylebox_override( + "panel", + _followup_card_box(_followup_is_dark(), bool(state.get("is_main_correct", false))) + ) - var teaching := Label.new() - teaching.text = str(state.get("teaching_point", "")) - teaching.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(teaching, 14, Style.AMBER) - content.add_child(teaching) + +func _followup_card_box(dark: bool, is_correct: bool) -> StyleBoxFlat: + var accent := Brand.SUCCESS if is_correct else Color("fb5f6d") + var box := StyleBoxFlat.new() + box.bg_color = Color("0b172b") if dark else Color("ffffff") + box.border_color = Color(accent, 0.72) + box.set_border_width_all(1) + box.set_corner_radius_all(7) + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _followup_body_box(dark: bool, is_correct: bool) -> StyleBoxFlat: + var accent := Brand.SUCCESS if is_correct else Color("fb5f6d") + var box := StyleBoxFlat.new() + box.bg_color = Color(accent, 0.07 if dark else 0.045) + box.border_color = Color(accent, 0.34) + box.set_border_width_all(1) + box.set_corner_radius_all(6) + box.content_margin_left = 14 + box.content_margin_right = 14 + box.content_margin_top = 12 + box.content_margin_bottom = 12 + return box + + +func _followup_is_dark() -> bool: + if theme_service == null: + return true + return theme_service.is_dark_mode() + + +func _choice_review_explanation(choice_id: String, state: Dictionary) -> String: + var question := BlockSessionService.get_current_question() + var explanations: Dictionary = question.get("choice_explanations", {}) + var explanation := str(explanations.get(choice_id, "")).strip_edges() + if explanation.begins_with("Correct. "): + explanation = explanation.substr(9) + if explanation != "": + return explanation + var teaching_point := str(state.get("teaching_point", "")).strip_edges() + if teaching_point != "": + return teaching_point + if bool(state.get("is_main_correct", false)): + return str(question.get("explanation", "Review the correct answer explanation.")) + return "Review the stem finding that makes this option less appropriate." + + +func _restart_followup(choice_id: String) -> void: + if followup_required or not followup_states.has(choice_id): + return + var state: Dictionary = followup_states[choice_id] + if not bool(state.get("has_authored_choices", false)): + return + var options: Array = state.get("choices", []).duplicate(true) + options.shuffle() + state["choices"] = options + state["selected_option_id"] = "" + state["selected_option_text"] = "" + state["was_correct"] = false + state["resolved"] = false + state["collapsed"] = false + followup_states[choice_id] = state + active_followup_choice_id = choice_id + followup_required = true + followup_answered = false + next_button.disabled = true + if skip_button != null: + skip_button.disabled = true + _render_followup_state(choice_id) + var index := _choice_index_for_id(choice_id) + if index >= 0 and question_scroll != null: + question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index]) + + +func _ensure_answer_review_followups() -> void: + for attempted_id in followup_states.keys(): + var attempted_state: Dictionary = followup_states[attempted_id] + if bool(attempted_state.get("resolved", false)): + attempted_state["collapsed"] = true + followup_states[attempted_id] = attempted_state + for choice_id in followup_attempt_order: + _render_followup_state(str(choice_id)) func _clear_followups() -> void: @@ -2385,6 +3204,7 @@ func _clear_followups() -> void: followup_required = false followup_answered = false followup_states.clear() + followup_attempt_order.clear() for index in range(choice_followup_panels.size()): _clear_followup_content(index) choice_followup_panels[index].visible = false @@ -2596,6 +3416,8 @@ func _draw_icon_line(image: Image, from: Vector2i, to: Vector2i, color: Color) - func _refresh_nav_palette() -> void: + if nav_palette == null: + return for child in nav_palette.get_children(): child.queue_free() @@ -2605,7 +3427,7 @@ func _refresh_nav_palette() -> void: for index in range(BlockSessionService.active_questions.size()): var button := Button.new() button.text = str(index + 1) - button.custom_minimum_size = Vector2(38, 34) + button.custom_minimum_size = Vector2(34, 34) var answer = BlockSessionService.get_answer_for_index(index) if index == BlockSessionService.current_index: Style.style_chip(button, Style.CYAN) @@ -2615,11 +3437,5 @@ func _refresh_nav_palette() -> void: Style.style_chip(button, Style.LIME) else: Style.style_chip(button, Color("596982")) - button.pressed.connect(func(target_index: int = index): - if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": - if BlockSessionService.mode == "Simulation": - _save_simulation_current_response() - BlockSessionService.move_to(target_index) - _load_question() - ) + button.pressed.connect(_navigate_to_question.bind(index)) nav_palette.add_child(button) diff --git a/tests/choice_pool_test.gd b/tests/choice_pool_test.gd index 6c9c08b..9311338 100644 --- a/tests/choice_pool_test.gd +++ b/tests/choice_pool_test.gd @@ -24,6 +24,10 @@ func _initialize() -> void: var legacy: Dictionary = service._prepare_question_choices(_question(5)) _check_presented(legacy) _assert(_ids(legacy.get("presented_choices", [])).size() == 5, "legacy five-choice questions must show all choices") + service.session_kind = "block" + service.session_initial_seconds = 900.0 + service.seconds_remaining = 450.0 + _assert(is_equal_approx(service.get_session_timer_ratio(), 0.5), "timed block progress should use the full session duration") service.free() quit(1 if failures > 0 else 0) diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 419bf31..1ec87f9 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -11,10 +11,17 @@ func _initialize() -> void: _test_eliminated_choice_uses_rich_text_strikeout() _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() + await _test_player_runtime_construction() quit(1 if failures > 0 else 0) @@ -93,7 +100,7 @@ func _test_eliminated_choice_uses_rich_text_strikeout() -> void: 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(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() @@ -133,9 +140,10 @@ func _test_collapsed_followup_review_expands() -> void: "teaching_point": "Review the finding.", "resolved": true, "collapsed": true } player._render_followup_state("B") - var review_button := content.get_child(0) as Button + var review_button := content.find_child("FollowupSummary", true, false) as Button _assert(review_button != null, "collapsed follow-up should render a review button") - review_button.pressed.emit() + 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() @@ -145,6 +153,90 @@ func _test_collapsed_followup_review_expands() -> void: 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") @@ -189,6 +281,56 @@ func _test_feedback_form_validation() -> void: _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.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") + 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