From 705f079997253469741146517cab4f96638a4e3c Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 14 Jul 2026 17:49:23 -0500 Subject: [PATCH] Add functional simulation question review --- api/QuestionsApi.Tests/ChoicePoolTests.cs | 12 + .../Controllers/ExamSimulationsController.cs | 4 + api/QuestionsApi/Models/ExamSimulation.cs | 18 + api/QuestionsApi/Models/QuestionResponse.cs | 18 + .../Services/ExamSimulationStore.cs | 35 +- scripts/autoload/exam_simulation_service.gd | 20 + scripts/ui/main.gd | 6 + scripts/ui/simulation_results.gd | 38 +- scripts/ui/simulation_review.gd | 342 ++++++++++++++++++ scripts/ui/simulation_review.gd.uid | 1 + scripts/ui/study_selector.gd | 49 +++ tests/simulation_review_test.gd | 39 ++ tests/simulation_review_test.gd.uid | 1 + 13 files changed, 551 insertions(+), 32 deletions(-) create mode 100644 scripts/ui/simulation_review.gd create mode 100644 scripts/ui/simulation_review.gd.uid create mode 100644 tests/simulation_review_test.gd create mode 100644 tests/simulation_review_test.gd.uid diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index d0cc440..88b3e41 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -32,6 +32,18 @@ public sealed class ChoicePoolTests Assert.Equal("https://example.org/guideline", response.References[0].Url); } + [Fact] + public void ExamReviewContainsOnlyChoicesPresentedDuringTheExam() + { + var question = BuildQuestion(9); + var presented = new[] { "F", "A", "C", "H", "D" }; + var response = QuestionResponse.FromModelWithChoices(question, presented); + + Assert.Equal(presented, response.Choices.Select(choice => choice.Id)); + Assert.Equal(presented.Order(), response.ChoiceExplanations.Keys.Order()); + Assert.DoesNotContain("B", response.Choices.Select(choice => choice.Id)); + } + [Fact] public void PublishedQuestionRequiresCompletePerChoiceTeachingContent() { diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index fa3e5e4..8e93819 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -40,6 +40,10 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation)); } + [HttpGet("completed")] + public async Task Completed([FromQuery] int limit = 20, [FromQuery] DateTime? before = null) => + Ok(await simulations.GetCompletedAsync(UserId(), limit, before)); + [HttpGet("{id}")] public async Task Get(string id) { diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 51b4c2d..a919783 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -119,6 +119,24 @@ public class ExamSimulationReadinessResponse public bool HasActiveSimulation { get; set; } } +public class CompletedExamSimulationSummary +{ + public string Id { get; set; } = ""; + public DateTime CompletedUtc { get; set; } + public int PredictedScore { get; set; } + public int RangeLow { get; set; } + public int RangeHigh { get; set; } + public int Correct { get; set; } + public int Total { get; set; } + public int SeenCount { get; set; } +} + +public class CompletedExamSimulationPage +{ + public List Items { get; set; } = []; + public DateTime? NextBefore { get; set; } +} + public class Step3Outcome { [BsonId] diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index a13b33e..287f874 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -62,6 +62,24 @@ public class QuestionResponse }; } + public static QuestionResponse FromModelWithChoices( + Question question, + IReadOnlyCollection presentedChoiceIds, + int questionElo = 1000) + { + var response = FromModel(question, questionElo: questionElo, includeChoicePool: true); + var ids = presentedChoiceIds.ToHashSet(StringComparer.Ordinal); + response.Choices = presentedChoiceIds + .Select(id => question.Choices.FirstOrDefault(choice => choice.Id == id)) + .Where(choice => choice is not null) + .Cast() + .ToList(); + response.ChoiceExplanations = response.ChoiceExplanations.Where(entry => ids.Contains(entry.Key)).ToDictionary(); + response.WrongAnswerFollowups = response.WrongAnswerFollowups.Where(entry => ids.Contains(entry.Key)).ToDictionary(); + response.ChoiceSelectionPercentages = response.ChoiceSelectionPercentages.Where(entry => ids.Contains(entry.Key)).ToDictionary(); + return response; + } + private static List SelectLegacyChoices(Question question) { if (question.Choices.Count <= 5) diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 3028e8d..1b21664 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -33,6 +33,8 @@ public class ExamSimulationStore _metrics = metrics; _simulations.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State))); + _simulations.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc))); _simulations.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(item => item.UpdatedUtc))); _outcomes.Indexes.CreateOne(new CreateIndexModel( @@ -136,8 +138,8 @@ public class ExamSimulationStore Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap), BlueprintTargets = targets, BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()), - ReviewQuestions = items.Select(item => QuestionResponse.FromModel( - questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList() + ReviewQuestions = items.Select(item => QuestionResponse.FromModelWithChoices( + questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList() }; } @@ -154,6 +156,33 @@ public class ExamSimulationStore return simulation; } + public async Task GetCompletedAsync(string userId, int limit, DateTime? before) + { + limit = Math.Clamp(limit, 1, 50); + var filter = Builders.Filter.Eq(item => item.UserId, userId) & + Builders.Filter.Eq(item => item.State, ExamSimulationStates.Completed); + if (before is not null) + filter &= Builders.Filter.Lt(item => item.CompletedUtc, before.Value); + var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync(); + var hasMore = simulations.Count > limit; + if (hasMore) simulations.RemoveAt(simulations.Count - 1); + return new CompletedExamSimulationPage + { + Items = simulations.Select(item => new CompletedExamSimulationSummary + { + Id = item.Id ?? "", + CompletedUtc = item.CompletedUtc ?? item.UpdatedUtc, + PredictedScore = item.Score?.PredictedScore ?? 0, + RangeLow = item.Score?.RangeLow ?? 0, + RangeHigh = item.Score?.RangeHigh ?? 0, + Correct = item.Score?.Correct ?? 0, + Total = item.Score?.Total ?? item.Items.Count, + SeenCount = item.Score?.SeenCount ?? 0 + }).ToList(), + NextBefore = hasMore && simulations.Count > 0 ? simulations[^1].CompletedUtc : null + }; + } + public async Task StartNextBlockAsync(ExamSimulation simulation) { if (simulation.State != ExamSimulationStates.BetweenBlocks) @@ -244,7 +273,7 @@ public class ExamSimulationStore { response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position) .Where(item => questionMap.ContainsKey(item.QuestionExternalId)) - .Select(item => QuestionResponse.FromModel(questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList(); + .Select(item => QuestionResponse.FromModelWithChoices(questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList(); } return response; } diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 0bffa07..467d1fa 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -7,6 +7,7 @@ signal readiness_changed signal state_changed signal request_failed(message: String) signal outcome_changed +signal completed_history_changed var api_url := DEFAULT_API_URL var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes" @@ -14,6 +15,8 @@ var request: HTTPRequest var readiness := {} var simulation := {} var outcome := {} +var completed_history: Array = [] +var completed_history_next_before := "" var is_loading := false var pending_action := "" var request_queue: Array = [] @@ -37,6 +40,17 @@ func load_active() -> void: _send("active", HTTPClient.METHOD_GET, "/active") +func load_completed_history(before: String = "") -> void: + var suffix := "/completed?limit=20" + if not before.is_empty(): + suffix += "&before=%s" % before.uri_encode() + _send("history_append" if not before.is_empty() else "history", HTTPClient.METHOD_GET, suffix) + + +func load_simulation(simulation_id: String) -> void: + _send("completed_get", HTTPClient.METHOD_GET, "/%s" % simulation_id.uri_encode()) + + func create_simulation() -> void: _send("create", HTTPClient.METHOD_POST, "") @@ -156,6 +170,12 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri readiness = parsed readiness_changed.emit() return + if action == "history" or action == "history_append": + var items: Array = parsed.get("items", []) + completed_history = completed_history + items if action == "history_append" else items + completed_history_next_before = str(parsed.get("nextBefore", "")) + completed_history_changed.emit() + return if action.begins_with("outcome_"): outcome = parsed outcome_changed.emit() diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 64bb21f..a2bcd54 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -167,6 +167,12 @@ func _build_ui() -> void: screens_container.add_child(simulation_results) screens["simulation_results"] = simulation_results + var simulation_review := preload("res://scripts/ui/simulation_review.gd").new() + simulation_review.name = "SimulationReview" + simulation_review.main = self + screens_container.add_child(simulation_review) + screens["simulation_review"] = simulation_review + var profile := preload("res://scripts/ui/progress_profile.gd").new() profile.name = "ProgressProfile" profile.main = self diff --git a/scripts/ui/simulation_results.gd b/scripts/ui/simulation_results.gd index 401bdeb..f0418da 100644 --- a/scripts/ui/simulation_results.gd +++ b/scripts/ui/simulation_results.gd @@ -6,12 +6,12 @@ var main: Control var score_label: Label var summary_label: Label var category_list: VBoxContainer -var review_list: VBoxContainer var outcome_score: SpinBox var outcome_date: LineEdit var outcome_consent: CheckBox var outcome_status: Label var outcome_delete_button: Button +var current_simulation := {} func _ready() -> void: @@ -42,13 +42,13 @@ func _ready() -> void: content.add_child(category_heading) category_list = VBoxContainer.new() content.add_child(category_list) - var review_heading := Label.new() - review_heading.text = "Answer review" - Style.set_label(review_heading, 22, Style.TEXT) - content.add_child(review_heading) - review_list = VBoxContainer.new() - review_list.add_theme_constant_override("separation", 8) - content.add_child(review_list) + var review_button := Button.new() + review_button.text = "Review Questions" + review_button.custom_minimum_size = Vector2(240, 50) + review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_primary_button(review_button) + review_button.pressed.connect(func(): main.show_screen("simulation_review", current_simulation)) + content.add_child(review_button) var outcome_heading := Label.new() outcome_heading.text = "Help calibrate future predictions" @@ -102,6 +102,7 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation + current_simulation = simulation var score: Dictionary = simulation.get("score", {}) var predicted := int(score.get("predictedScore", 0)) var low := int(score.get("rangeLow", 0)) @@ -117,30 +118,9 @@ func on_show(payload: Variant = null) -> void: label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))] Style.set_label(label, 16, Style.TEXT) category_list.add_child(label) - _build_review(simulation) ExamSimulationService.load_outcome() -func _build_review(simulation: Dictionary) -> void: - _clear(review_list) - var responses := {} - for response in simulation.get("responses", []): - responses[str(response.get("questionExternalId", ""))] = response - for raw_question in simulation.get("reviewQuestions", []): - if typeof(raw_question) != TYPE_DICTIONARY: - continue - var question: Dictionary = raw_question - var id := str(question.get("externalId", "")) - var selected := str(responses.get(id, {}).get("choiceId", "")) - var correct := str(question.get("correctChoiceId", "")) - var item := RichTextLabel.new() - item.bbcode_enabled = true - item.fit_content = true - item.custom_minimum_size = Vector2(0, 84) - item.text = "[b]%s[/b] %s\nSelected: %s | Correct: %s\n%s" % ["CORRECT" if selected == correct else "MISS", str(question.get("leadIn", "")), selected if selected != "" else "Unanswered", correct, str(question.get("explanation", ""))] - review_list.add_child(item) - - func _clear(container: Control) -> void: for child in container.get_children(): child.queue_free() diff --git a/scripts/ui/simulation_review.gd b/scripts/ui/simulation_review.gd new file mode 100644 index 0000000..a40e029 --- /dev/null +++ b/scripts/ui/simulation_review.gd @@ -0,0 +1,342 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") +const BLOCK_SIZES := [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19] + +var main: Control +var simulation := {} +var questions: Array = [] +var responses := {} +var filtered_indices: Array[int] = [] +var review_position := 0 +var active_filter := "all" +var progress_label: Label +var content: VBoxContainer +var previous_button: Button +var next_button: Button +var empty_label: Label +var media_requests: Array[HTTPRequest] = [] +var image_overlay: Control +var image_texture: TextureRect + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 10) + var toolbar := HBoxContainer.new() + toolbar.add_theme_constant_override("separation", 8) + add_child(toolbar) + var back := Button.new() + back.text = "< Results" + Style.style_chip(back, Style.CYAN) + back.pressed.connect(func(): main.show_screen("simulation_results", simulation)) + toolbar.add_child(back) + progress_label = Label.new() + progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(progress_label, 17, Style.TEXT) + toolbar.add_child(progress_label) + var filters := GridContainer.new() + filters.columns = 4 + filters.add_theme_constant_override("h_separation", 8) + filters.size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_child(filters) + var group := ButtonGroup.new() + for definition in [["All", "all"], ["Incorrect", "incorrect"], ["Unanswered", "unanswered"], ["Flagged", "flagged"]]: + var button := Button.new() + button.text = definition[0] + button.toggle_mode = true + button.button_group = group + button.button_pressed = definition[1] == "all" + Style.style_chip(button, Style.CYAN) + button.pressed.connect(_set_filter.bind(definition[1])) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + filters.add_child(button) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_child(scroll) + content = VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 10) + scroll.add_child(content) + empty_label = Label.new() + empty_label.visible = false + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(empty_label, 19, Style.MUTED) + content.add_child(empty_label) + + var navigation := HBoxContainer.new() + navigation.add_theme_constant_override("separation", 10) + add_child(navigation) + previous_button = Button.new() + previous_button.text = "< Previous" + previous_button.custom_minimum_size = Vector2(180, 48) + Style.style_chip(previous_button, Style.CYAN) + previous_button.pressed.connect(_move.bind(-1)) + navigation.add_child(previous_button) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + navigation.add_child(spacer) + next_button = Button.new() + next_button.text = "Next >" + next_button.custom_minimum_size = Vector2(180, 48) + Style.style_primary_button(next_button) + next_button.pressed.connect(_move.bind(1)) + navigation.add_child(next_button) + _build_image_overlay() + + +func on_show(payload: Variant = null) -> void: + if typeof(payload) == TYPE_DICTIONARY: + simulation = payload + questions = simulation.get("reviewQuestions", []) + responses.clear() + for response in simulation.get("responses", []): + responses[str(response.get("questionExternalId", ""))] = response + active_filter = "all" + review_position = 0 + _apply_filter() + + +func on_hide() -> void: + _clear_media_requests() + image_overlay.visible = false + + +func _unhandled_key_input(event: InputEvent) -> void: + if not visible or not event.pressed or event.echo: + return + if event.keycode == KEY_LEFT: + _move(-1) + get_viewport().set_input_as_handled() + elif event.keycode == KEY_RIGHT: + _move(1) + get_viewport().set_input_as_handled() + elif event.keycode == KEY_ESCAPE and image_overlay.visible: + image_overlay.visible = false + get_viewport().set_input_as_handled() + + +func _set_filter(filter_name: String) -> void: + active_filter = filter_name + review_position = 0 + _apply_filter() + + +func _apply_filter() -> void: + filtered_indices.clear() + for index in range(questions.size()): + var question: Dictionary = questions[index] + var response: Dictionary = responses.get(str(question.get("externalId", "")), {}) + var selected := str(response.get("choiceId", "")) + var correct := str(question.get("correctChoiceId", "")) + if _matches_filter(active_filter, selected, correct, bool(response.get("flagged", false))): + filtered_indices.append(index) + _render_question() + + +func _move(direction: int) -> void: + var target := review_position + direction + if target < 0 or target >= filtered_indices.size(): + return + review_position = target + _render_question() + + +func _render_question() -> void: + _clear_content() + _clear_media_requests() + if filtered_indices.is_empty(): + empty_label = Label.new() + empty_label.text = "No questions match this filter." + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + Style.set_label(empty_label, 19, Style.MUTED) + content.add_child(empty_label) + progress_label.text = "0 questions" + previous_button.disabled = true + next_button.disabled = true + return + var question_index := filtered_indices[review_position] + var question: Dictionary = questions[question_index] + var response: Dictionary = responses.get(str(question.get("externalId", "")), {}) + var selected := str(response.get("choiceId", "")) + var correct := str(question.get("correctChoiceId", "")) + progress_label.text = "Question %d of %d | Block %d | %s" % [review_position + 1, filtered_indices.size(), _block_for_index(question_index) + 1, "Correct" if selected == correct else "Unanswered" if selected == "" else "Incorrect"] + previous_button.disabled = review_position == 0 + next_button.disabled = review_position + 1 >= filtered_indices.size() + _add_text(str(question.get("stem", "")), 18, Style.TEXT) + _add_media(question.get("media", [])) + _add_labs(question.get("labs", [])) + _add_text(str(question.get("leadIn", "")), 21, Style.TEXT) + var explanations: Dictionary = question.get("choiceExplanations", {}) + for raw_choice in question.get("choices", []): + var choice: Dictionary = raw_choice + var choice_id := str(choice.get("id", "")) + var panel := PanelContainer.new() + var border := Style.LIME if choice_id == correct else Style.RED if choice_id == selected else Style.BORDER + var background := Color("1f7a3a") if choice_id == correct else Color("8f1d2c") if choice_id == selected else Style.SURFACE + panel.add_theme_stylebox_override("panel", Style.panel_box(background, border)) + content.add_child(panel) + var choice_content := VBoxContainer.new() + panel.add_child(choice_content) + var suffix := " [Correct answer]" if choice_id == correct else " [Your answer]" if choice_id == selected else "" + var answer := Label.new() + answer.text = "%s. %s%s" % [choice_id, str(choice.get("text", "")), suffix] + answer.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(answer, 17, Style.TEXT) + choice_content.add_child(answer) + var explanation := str(explanations.get(choice_id, "")) + if not explanation.is_empty(): + var detail := Label.new() + detail.text = explanation + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(detail, 15, Style.TEXT) + choice_content.add_child(detail) + _add_heading("Explanation") + _add_text(str(question.get("explanation", "")), 17, Style.TEXT) + _add_references(question.get("references", [])) + + +func _add_heading(value: String) -> void: + var label := Label.new() + label.text = value + Style.set_label(label, 20, Style.CYAN) + content.add_child(label) + + +func _add_text(value: String, size_value: int, color: Color) -> void: + var label := Label.new() + label.text = value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(label, size_value, color) + content.add_child(label) + + +func _add_labs(labs: Array) -> void: + if labs.is_empty(): + return + _add_heading("Laboratory studies") + for raw_lab in labs: + var lab: Dictionary = raw_lab + _add_text("%s: %s" % [str(lab.get("name", lab.get("test", ""))), str(lab.get("value", ""))], 15, Style.TEXT) + + +func _add_references(references: Array) -> void: + if references.is_empty(): + return + _add_heading("References") + for raw_reference in references: + var reference: Dictionary = raw_reference + var url := str(reference.get("url", "")) + if not url.begins_with("https://"): + continue + var link := LinkButton.new() + link.text = str(reference.get("title", url)) + link.tooltip_text = url + link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + link.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + link.add_theme_color_override("font_color", Style.CYAN) + link.pressed.connect(func(target_url: String = url): OS.shell_open(target_url)) + content.add_child(link) + + +func _add_media(media: Array) -> void: + for raw_url in media: + var url := _resolve_media_url(str(raw_url)) + if url.is_empty(): + continue + var preview := TextureRect.new() + preview.custom_minimum_size = Vector2(0, 260) + preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + preview.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + content.add_child(preview) + var request := HTTPRequest.new() + request.request_completed.connect(func(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray): _on_media_loaded(result, code, body, preview)) + media_requests.append(request) + add_child(request) + request.request(url) + + +func _on_media_loaded(result: int, code: int, body: PackedByteArray, preview: TextureRect) -> void: + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300 or not is_instance_valid(preview): + return + var image := Image.new() + var error := image.load_png_from_buffer(body) + if error != OK: error = image.load_jpg_from_buffer(body) + if error != OK: error = image.load_webp_from_buffer(body) + if error != OK: return + preview.texture = ImageTexture.create_from_image(image) + preview.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + image_texture.texture = preview.texture + image_overlay.visible = true + ) + + +func _build_image_overlay() -> void: + image_overlay = Control.new() + image_overlay.visible = false + image_overlay.top_level = true + image_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + image_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(image_overlay) + var background := ColorRect.new() + background.color = Color(Style.BACKGROUND, 0.97) + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + image_overlay.add_child(background) + image_texture = TextureRect.new() + image_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + image_texture.offset_left = 24 + image_texture.offset_top = 72 + image_texture.offset_right = -24 + image_texture.offset_bottom = -24 + image_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + image_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + image_overlay.add_child(image_texture) + var close := Button.new() + close.text = "X" + close.position = Vector2(16, 16) + close.custom_minimum_size = Vector2(44, 44) + Style.style_chip(close, Style.CYAN) + close.pressed.connect(func(): image_overlay.visible = false) + image_overlay.add_child(close) + + +func _resolve_media_url(value: String) -> String: + if value.begins_with("http://") or value.begins_with("https://"): + return value + var path := value if value.begins_with("/") else "/%s" % value + var base := QuestionBankService.question_api_base_url + var scheme_end := base.find("://") + var path_start := base.find("/", scheme_end + 3) + return "%s%s" % [base if path_start < 0 else base.substr(0, path_start), path] + + +func _clear_content() -> void: + for child in content.get_children(): + child.queue_free() + + +func _clear_media_requests() -> void: + for request in media_requests: + if is_instance_valid(request): request.queue_free() + media_requests.clear() + + +func _block_for_index(index: int) -> int: + var consumed := 0 + for block in range(BLOCK_SIZES.size()): + if index < consumed + BLOCK_SIZES[block]: return block + consumed += BLOCK_SIZES[block] + return BLOCK_SIZES.size() - 1 + + +static func _matches_filter(filter_name: String, selected: String, correct: String, flagged: bool) -> bool: + match filter_name: + "incorrect": return selected != "" and selected != correct + "unanswered": return selected == "" + "flagged": return flagged + _: return true diff --git a/scripts/ui/simulation_review.gd.uid b/scripts/ui/simulation_review.gd.uid new file mode 100644 index 0000000..9938823 --- /dev/null +++ b/scripts/ui/simulation_review.gd.uid @@ -0,0 +1 @@ +uid://cyt8fuoixldi2 diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index e5a1483..3cae5ee 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -14,6 +14,8 @@ var retry_button: Button var simulation_button: Button var simulation_status_label: Label var simulation_preview_link: LinkButton +var completed_history_list: VBoxContainer +var completed_history_more: Button var pending_simulation_launch := false var current_stage := "exam" var pending_question_bank_launch := false @@ -35,6 +37,8 @@ func _ready() -> void: ExamSimulationService.request_failed.connect(_on_simulation_failed) if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility): AuthState.auth_changed.connect(_refresh_simulation_preview_visibility) + if not ExamSimulationService.completed_history_changed.is_connected(_refresh_completed_history): + ExamSimulationService.completed_history_changed.connect(_refresh_completed_history) _build_exam_view() _build_mode_view() @@ -49,6 +53,7 @@ func on_show(payload: Variant = null) -> void: _refresh_bank_state() _refresh_simulation_preview_visibility() ExamSimulationService.load_readiness() + ExamSimulationService.load_completed_history() func on_hide() -> void: @@ -174,6 +179,26 @@ func _build_mode_view() -> void: mode_view.add_child(simulation_preview_link) _refresh_simulation_preview_visibility() + var history_heading := Label.new() + history_heading.text = "Completed simulations" + Style.set_label(history_heading, 20, Style.TEXT) + mode_view.add_child(history_heading) + completed_history_list = VBoxContainer.new() + completed_history_list.add_theme_constant_override("separation", 6) + completed_history_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var history_scroll := ScrollContainer.new() + history_scroll.custom_minimum_size = Vector2(0, 150) + history_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + history_scroll.add_child(completed_history_list) + mode_view.add_child(history_scroll) + completed_history_more = Button.new() + completed_history_more.text = "Load more" + completed_history_more.visible = false + completed_history_more.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(completed_history_more, Style.CYAN) + completed_history_more.pressed.connect(func(): ExamSimulationService.load_completed_history(ExamSimulationService.completed_history_next_before)) + mode_view.add_child(completed_history_more) + bank_status_label = Label.new() bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(bank_status_label, 16, Style.CYAN) @@ -269,6 +294,30 @@ func _refresh_simulation_preview_visibility() -> void: simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER" +func _refresh_completed_history() -> void: + if completed_history_list == null: + return + for child in completed_history_list.get_children(): + child.queue_free() + for raw_summary in ExamSimulationService.completed_history: + var summary: Dictionary = raw_summary + var button := Button.new() + button.text = "%s | Predicted %d (%d-%d) | %d/%d correct" % [ + str(summary.get("completedUtc", "")).left(10), int(summary.get("predictedScore", 0)), + int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0)), + int(summary.get("correct", 0)), int(summary.get("total", 0))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + Style.style_chip(button, Style.CYAN) + button.pressed.connect(_open_completed_simulation.bind(str(summary.get("id", "")))) + completed_history_list.add_child(button) + completed_history_more.visible = not ExamSimulationService.completed_history_next_before.is_empty() + + +func _open_completed_simulation(simulation_id: String) -> void: + pending_simulation_launch = true + ExamSimulationService.load_simulation(simulation_id) + + func _on_simulation_state_changed() -> void: _refresh_simulation_state() if not pending_simulation_launch or ExamSimulationService.simulation.is_empty(): diff --git a/tests/simulation_review_test.gd b/tests/simulation_review_test.gd new file mode 100644 index 0000000..0a3aa9d --- /dev/null +++ b/tests/simulation_review_test.gd @@ -0,0 +1,39 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + var review_script = load("res://scripts/ui/simulation_review.gd") + _assert(review_script._matches_filter("all", "A", "B", false), "all should include every response") + _assert(review_script._matches_filter("incorrect", "A", "B", false), "incorrect should include wrong answers") + _assert(not review_script._matches_filter("incorrect", "", "B", false), "incorrect should exclude unanswered questions") + _assert(review_script._matches_filter("unanswered", "", "B", false), "unanswered should include blank responses") + _assert(review_script._matches_filter("flagged", "B", "B", true), "flagged should include flagged correct responses") + _assert(not review_script._matches_filter("flagged", "A", "B", false), "flagged should exclude unflagged responses") + var review = review_script.new() + get_root().add_child(review) + await process_frame + review.on_show({ + "reviewQuestions": [ + {"externalId": "q1", "stem": "Stem one", "leadIn": "Lead one", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "A", "choiceExplanations": {"A": "Correct", "B": "Wrong"}, "explanation": "Overall", "labs": [], "media": [], "references": []}, + {"externalId": "q2", "stem": "Stem two", "leadIn": "Lead two", "choices": [{"id": "A", "text": "One"}, {"id": "B", "text": "Two"}], "correctChoiceId": "B", "choiceExplanations": {}, "explanation": "Overall", "labs": [], "media": [], "references": []} + ], + "responses": [{"questionExternalId": "q1", "choiceId": "B", "flagged": true}] + }) + _assert(review.filtered_indices.size() == 2, "all filter should build a complete review set") + review._set_filter("incorrect") + _assert(review.filtered_indices == [0], "incorrect filter should retain only answered misses") + review._set_filter("unanswered") + _assert(review.filtered_indices == [1], "unanswered filter should retain blank responses") + await process_frame + review.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/simulation_review_test.gd.uid b/tests/simulation_review_test.gd.uid new file mode 100644 index 0000000..56f09a5 --- /dev/null +++ b/tests/simulation_review_test.gd.uid @@ -0,0 +1 @@ +uid://dn2mvv1kmhup3