diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 9057954..275b1ec 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -3496,6 +3496,7 @@ func _build_followup_panel(_index: int, choice_id: String) -> void: "selected_option_text": "", "was_correct": false, "resolved": false, + "collapsed": false, "has_authored_choices": bool(followup.get("has_authored_choices", false)), "is_main_correct": false } @@ -3567,6 +3568,7 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se state["selected_option_text"] = selected_option_text state["was_correct"] = is_correct state["resolved"] = true + state["collapsed"] = false followup_states[resolved_choice_id] = state _render_followup_state(resolved_choice_id) @@ -3593,6 +3595,8 @@ func _render_followup_state(choice_id: String) -> void: _style_followup_card(index, state) if not bool(state.get("resolved", false)): _render_active_followup(index, state) + elif bool(state.get("collapsed", false)): + _build_resolved_followup_toggle(index, state, false) else: _build_resolved_followup(index, state) choice_followup_panels[index].visible = true @@ -3601,13 +3605,95 @@ func _render_followup_state(choice_id: String) -> void: func _build_resolved_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] + _build_resolved_followup_toggle(index, state, true) + + var prompt := Label.new() + prompt.name = "FollowupPrompt" + 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, Brand.text_color(_followup_is_dark())) + content.add_child(prompt) + + var selected_id := str(state.get("selected_option_id", "")) + var correct_id := str(state.get("correct_choice_id", "")) + var options: Array = state.get("choices", []) + for option_index in range(options.size()): + var option = options[option_index] + if typeof(option) != TYPE_DICTIONARY: + continue + var option_id := str(option.get("id", "")) + var option_button := Button.new() + option_button.name = "FollowupOption_%s" % option_id + option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))] + option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + option_button.disabled = true + option_button.custom_minimum_size = Vector2(0, 38) + _set_scaled_button_font(option_button, 14) + _style_review_followup_option( + option_button, + _followup_is_dark(), + option_id, + selected_id, + correct_id, + true + ) + content.add_child(option_button) + var explanation := Label.new() + explanation.name = "FollowupExplanation" explanation.text = _strip_verdict_prefix(_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())) content.add_child(explanation) +func _build_resolved_followup_toggle(index: int, state: Dictionary, expanded: bool) -> void: + var content := choice_followup_contents[index] + var dark := _followup_is_dark() + var has_authored_choices := bool(state.get("has_authored_choices", not state.get("choices", []).is_empty())) + var was_correct := bool(state.get("was_correct", false)) + var accent := Brand.SUCCESS if was_correct else Color("fb5f6d") if has_authored_choices else Brand.PURPLE + var row := HBoxContainer.new() + row.name = "FollowupResult" + row.add_theme_constant_override("separation", 8) + content.add_child(row) + + var result := Label.new() + result.name = "FollowupVerdict" + result.text = ("Correct" if was_correct else "Incorrect") if has_authored_choices else "Review complete" + result.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _set_scaled_label(result, 14, accent) + row.add_child(result) + + var toggle := Button.new() + toggle.name = "FollowupToggle" + toggle.flat = true + toggle.text = "Minimize" if expanded else "Expand" + toggle.tooltip_text = "Minimize follow-up review" if expanded else "Expand follow-up review" + toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg") + toggle.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT + toggle.custom_minimum_size = Vector2(110, 30) + toggle.add_theme_color_override("font_color", Brand.text_color(dark)) + toggle.add_theme_color_override("font_hover_color", accent) + toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark)) + toggle.add_theme_color_override("icon_hover_color", accent) + _set_scaled_button_font(toggle, 13) + toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", "")))) + row.add_child(toggle) + + +func _toggle_followup_state(choice_id: String) -> void: + if not followup_states.has(choice_id): + 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) + _apply_saved_choice_state() + + 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)) diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 41a5c1d..3d5f269 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -13,7 +13,7 @@ func _initialize() -> void: _test_eliminated_choice_uses_rich_text_strikeout() _test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through() _test_explanation_highlighting_has_no_status_heading() - _test_resolved_followup_has_no_header_or_verdict_prefix() + _test_resolved_followup_stays_expanded_and_toggles() _test_review_content_normalization() _test_followup_attempt_order_is_stable() _test_review_followup_is_optional_and_single_attempt() @@ -178,7 +178,7 @@ func _test_explanation_highlighting_has_no_status_heading() -> void: player.free() -func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void: +func _test_resolved_followup_stays_expanded_and_toggles() -> void: var player = load("res://scripts/ui/question_player.gd").new() var panel := PanelContainer.new() var content := VBoxContainer.new() @@ -192,17 +192,38 @@ func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void: player.presented_choices = [{"id": "B", "text": "Wrong answer"}] player.followup_states["B"] = { "choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?", - "selected_option_text": "Finding", "was_correct": false, - "teaching_point": "Incorrect. Review the finding.", "resolved": true + "choices": [{"id": "A", "text": "Selected finding"}, {"id": "B", "text": "Correct finding"}], + "correct_choice_id": "B", "selected_option_id": "A", + "selected_option_text": "A. Selected finding", "was_correct": false, + "teaching_point": "Incorrect. Review the finding.", "resolved": true, "collapsed": false, + "has_authored_choices": true } player._render_followup_state("B") - _assert(content.find_child("FollowupToggle", true, false) == null, "a resolved follow-up should not render a collapse toggle") - var explanation_text := "" - for child in content.get_children(): - if child is Label: - explanation_text += child.text - _assert(not explanation_text.contains("Why this is"), "a resolved follow-up should not repeat a 'Why this is...' heading") - _assert(not explanation_text.begins_with("Incorrect."), "a resolved follow-up explanation should not repeat the verdict prefix") + var toggle := content.find_child("FollowupToggle", true, false) as Button + _assert(toggle != null, "a resolved follow-up should render a minimize toggle") + _assert(toggle != null and toggle.text == "Minimize", "a newly resolved follow-up should remain expanded") + _assert(content.find_child("FollowupPrompt", true, false) != null, "the resolved follow-up should retain its prompt") + _assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option") + _assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option") + var verdict := content.find_child("FollowupVerdict", true, false) as Label + _assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct") + if toggle != null: + toggle.pressed.emit() + _assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up") + _assert(content.find_child("FollowupPrompt", true, false) == null, "a collapsed follow-up should hide its prompt") + toggle = content.find_child("FollowupToggle", true, false) as Button + _assert(toggle != null and toggle.text == "Expand", "a collapsed follow-up should render an expand toggle") + if toggle != null: + toggle.pressed.emit() + _assert(not bool(player.followup_states["B"].get("collapsed", true)), "the expand toggle should restore the follow-up") + _assert(content.find_child("FollowupPrompt", true, false) != null, "expanding should restore the follow-up prompt") + var correct_state: Dictionary = player.followup_states["B"] + correct_state["selected_option_id"] = "B" + correct_state["was_correct"] = true + player.followup_states["B"] = correct_state + player._render_followup_state("B") + verdict = content.find_child("FollowupVerdict", true, false) as Label + _assert(verdict != null and verdict.text == "Correct", "a correct follow-up response should show a correct verdict") player.choice_buttons[0].free() player.choice_percent_labels[0].free() player.choice_top_labels[0].free()