Implement nested question remediation and review content
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -15,6 +15,9 @@ public class Question
|
||||
public List<QuestionChoice> 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<QuestionReviewFollowup> ReviewFollowups { get; set; } = [];
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
|
||||
public Dictionary<string, int> 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<QuestionChoice> Choices { get; set; } = [];
|
||||
public string CorrectChoiceId { get; set; } = "";
|
||||
public string TeachingPoint { get; set; } = "";
|
||||
}
|
||||
|
||||
public class QuestionReference
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
@@ -59,6 +59,9 @@ public class QuestionReportSnapshot
|
||||
public List<QuestionChoice> 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<QuestionReviewFollowup> ReviewFollowups { get; set; } = [];
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
|
||||
public List<QuestionLab> Labs { get; set; } = [];
|
||||
|
||||
@@ -21,6 +21,9 @@ public class UpsertQuestionRequest
|
||||
public List<QuestionChoice> 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<QuestionReviewFollowup> ReviewFollowups { get; set; } = [];
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
|
||||
@@ -9,6 +9,9 @@ public class QuestionResponse
|
||||
public List<QuestionChoice> 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<QuestionReviewFollowup> ReviewFollowups { get; set; } = [];
|
||||
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
|
||||
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
|
||||
public Dictionary<string, int> 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(),
|
||||
|
||||
@@ -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<string, string>(question.ChoiceExplanations),
|
||||
WrongAnswerFollowups = question.WrongAnswerFollowups.ToDictionary(
|
||||
item => item.Key,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-3
@@ -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:
|
||||
|
||||
+1054
-238
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
@@ -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,8 +140,9 @@ 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")
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user