From bbb373acf426e8ba2136380ad046cf4e8b48783f Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 14 Jul 2026 17:25:25 -0500 Subject: [PATCH] Add SUPER exam results preview --- .../Controllers/ExamSimulationsController.cs | 8 +++ .../Services/ExamSimulationStore.cs | 70 +++++++++++++++++++ scripts/autoload/exam_simulation_service.gd | 4 ++ scripts/ui/study_selector.gd | 27 +++++++ 4 files changed, 109 insertions(+) diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 3f8ecd2..fa3e5e4 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -25,6 +25,14 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro catch (InvalidOperationException exception) { return Conflict(exception.Message); } } + [HttpGet("preview-results")] + [Authorize(Roles = "SUPER")] + public async Task PreviewResults() + { + try { return Ok(await simulations.BuildResultsPreviewAsync(UserId())); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + [HttpGet("active")] public async Task Active() { diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 663c313..3028e8d 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -93,6 +93,54 @@ public class ExamSimulationStore return simulation; } + public async Task BuildResultsPreviewAsync(string userId) + { + var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); + if (bank.Count < RequiredQuestions) + throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required."); + var targets = BuildTargets(); + var selected = SelectForm(bank, [], targets); + var items = selected.Select((question, index) => new ExamSimulationItem + { + QuestionExternalId = question.ExternalId, + BlockIndex = BlockIndexForPosition(index), + Position = PositionWithinBlock(index), + PresentedChoiceIds = SelectChoices(question), + QuestionElo = 1000, + WasSeen = false, + BlueprintCategory = BlueprintCategory(question) + }).ToList(); + var questionMap = selected.ToDictionary(question => question.ExternalId, StringComparer.Ordinal); + var responses = items.Select((item, index) => + { + var question = questionMap[item.QuestionExternalId]; + var choiceId = index % 3 == 0 + ? item.PresentedChoiceIds.First(id => id != question.CorrectChoiceId) + : question.CorrectChoiceId; + return new ExamSimulationResponse + { + QuestionExternalId = item.QuestionExternalId, + ChoiceId = choiceId, + ElapsedSeconds = 75 + index % 45, + Flagged = index % 11 == 0 + }; + }).ToList(); + var responseMap = responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal); + return new ExamSimulationStateResponse + { + Id = "preview", + State = ExamSimulationStates.Completed, + CurrentBlock = BlockSizes.Length - 1, + BreakSecondsRemaining = 3300, + Responses = responses, + 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() + }; + } + public async Task FindActiveAsync(string userId) => await _simulations.Find(item => item.UserId == userId && (item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive)) @@ -261,6 +309,28 @@ public class ExamSimulationStore private Task ReplaceAsync(ExamSimulation simulation) => _simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation); + private static int BlockIndexForPosition(int position) + { + var consumed = 0; + for (var block = 0; block < BlockSizes.Length; block++) + { + if (position < consumed + BlockSizes[block]) return block; + consumed += BlockSizes[block]; + } + return BlockSizes.Length - 1; + } + + private static int PositionWithinBlock(int position) + { + var consumed = 0; + foreach (var size in BlockSizes) + { + if (position < consumed + size) return position - consumed; + consumed += size; + } + return 0; + } + private static ExamSimulationQuestionResponse SafeQuestion(Question question, List presentedIds) => new() { ExternalId = question.ExternalId, diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 9e3866d..ebb22e3 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -41,6 +41,10 @@ func create_simulation() -> void: _send("create", HTTPClient.METHOD_POST, "") +func load_results_preview() -> void: + _send("preview", HTTPClient.METHOD_GET, "/preview-results") + + func start_next_block() -> void: if simulation.is_empty(): return diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index 1407291..082bb37 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -13,6 +13,7 @@ var bank_error_label: Label var retry_button: Button var simulation_button: Button var simulation_status_label: Label +var simulation_preview_link: LinkButton var pending_simulation_launch := false var current_stage := "exam" var pending_question_bank_launch := false @@ -32,6 +33,8 @@ func _ready() -> void: ExamSimulationService.state_changed.connect(_on_simulation_state_changed) if not ExamSimulationService.request_failed.is_connected(_on_simulation_failed): 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) _build_exam_view() _build_mode_view() @@ -44,6 +47,7 @@ func on_show(payload: Variant = null) -> void: elif str(payload) == "exam": _show_stage("exam") _refresh_bank_state() + _refresh_simulation_preview_visibility() ExamSimulationService.load_readiness() @@ -160,6 +164,16 @@ func _build_mode_view() -> void: Style.set_label(simulation_status_label, 15, Style.MUTED) mode_view.add_child(simulation_status_label) + simulation_preview_link = LinkButton.new() + simulation_preview_link.text = "Temporary: preview completed exam results" + simulation_preview_link.tooltip_text = "SUPER only; generates a nonpersistent simulated result" + simulation_preview_link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + simulation_preview_link.add_theme_color_override("font_color", Style.CYAN) + simulation_preview_link.add_theme_color_override("font_hover_color", Style.MAGENTA) + simulation_preview_link.pressed.connect(_preview_simulation_results) + mode_view.add_child(simulation_preview_link) + _refresh_simulation_preview_visibility() + bank_status_label = Label.new() bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(bank_status_label, 16, Style.CYAN) @@ -243,6 +257,17 @@ func _select_simulation() -> void: ExamSimulationService.create_simulation() +func _preview_simulation_results() -> void: + pending_simulation_launch = true + simulation_preview_link.disabled = true + ExamSimulationService.load_results_preview() + + +func _refresh_simulation_preview_visibility() -> void: + if simulation_preview_link != null: + simulation_preview_link.visible = AuthState.role.to_upper() == "SUPER" + + func _on_simulation_state_changed() -> void: _refresh_simulation_state() if not pending_simulation_launch or ExamSimulationService.simulation.is_empty(): @@ -269,6 +294,8 @@ func _launch_simulation_block() -> void: func _on_simulation_failed(message: String) -> void: pending_simulation_launch = false + if simulation_preview_link != null: + simulation_preview_link.disabled = false if simulation_status_label != null: simulation_status_label.text = message _refresh_simulation_state()