diff --git a/api/QuestionsApi.Tests/ExamSimulationTests.cs b/api/QuestionsApi.Tests/ExamSimulationTests.cs index 1b71a17..b31a2af 100644 --- a/api/QuestionsApi.Tests/ExamSimulationTests.cs +++ b/api/QuestionsApi.Tests/ExamSimulationTests.cs @@ -83,6 +83,7 @@ public sealed class ExamSimulationTests var completed = ExamSimulationStore.ToHistorySummary(new ExamSimulation { State = ExamSimulationStates.Completed, + IsSample = true, Items = BuildItems(20), Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, Correct = 15, Total = 20 } }); @@ -91,6 +92,7 @@ public sealed class ExamSimulationTests Assert.Equal(3, active.CurrentBlock); Assert.Equal(221, completed.PredictedScore); Assert.Equal(15, completed.Correct); + Assert.True(completed.IsSample); } private static List BuildItems(int count, bool wasSeen = false) => diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 6664c70..37fb258 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -33,6 +33,14 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro catch (InvalidOperationException exception) { return Conflict(exception.Message); } } + [HttpPost("sample-completed")] + [Authorize(Roles = "SUPER")] + public async Task CreateSampleCompleted() + { + try { return Ok(ExamSimulationStore.ToHistorySummary(await simulations.CreateSampleCompletedAsync(UserId()))); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + [HttpGet("active")] public async Task Active() { diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 62d02d6..801d508 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -20,6 +20,7 @@ public class ExamSimulation public string UserId { get; set; } = ""; public string State { get; set; } = ExamSimulationStates.BetweenBlocks; public string ModelVersion { get; set; } = ExamSimulationScoring.ModelVersion; + public bool IsSample { get; set; } public List Items { get; set; } = []; public List Responses { get; set; } = []; public int CurrentBlock { get; set; } = -1; @@ -141,6 +142,7 @@ public class ExamSimulationHistorySummary { public string Id { get; set; } = ""; public string State { get; set; } = ""; + public bool IsSample { get; set; } public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } public DateTime? CompletedUtc { get; set; } diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 4dd6f68..71534f5 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -97,7 +97,12 @@ public class ExamSimulationStore return simulation; } - public async Task BuildResultsPreviewAsync(string userId) + public async Task BuildResultsPreviewAsync(string userId) => + await ToResponseAsync(await BuildSampleAsync(userId, false)); + + public Task CreateSampleCompletedAsync(string userId) => BuildSampleAsync(userId, true); + + private async Task BuildSampleAsync(string userId, bool persist) { var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); if (bank.Count < RequiredQuestions) @@ -130,19 +135,25 @@ public class ExamSimulationStore }; }).ToList(); var responseMap = responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal); - return new ExamSimulationStateResponse + var now = DateTime.UtcNow; + var simulation = new ExamSimulation { - Id = "preview", + UserId = userId, State = ExamSimulationStates.Completed, + IsSample = true, CurrentBlock = BlockSizes.Length - 1, BreakSecondsRemaining = 3300, + Items = items, Responses = responses, Score = ExamSimulationScoring.Calculate(items, responseMap, questionMap), + CreatedUtc = now, + UpdatedUtc = now, + CompletedUtc = now, BlueprintTargets = targets, - BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()), - ReviewQuestions = items.Select(item => QuestionResponse.FromModelWithChoices( - questionMap[item.QuestionExternalId], item.PresentedChoiceIds, item.QuestionElo)).ToList() + BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()) }; + if (persist) await _simulations.InsertOneAsync(simulation); + return simulation; } public async Task FindActiveAsync(string userId) => @@ -205,6 +216,7 @@ public class ExamSimulationStore { Id = item.Id ?? "", State = item.State, + IsSample = item.IsSample, CreatedUtc = item.CreatedUtc, UpdatedUtc = item.UpdatedUtc, CompletedUtc = item.CompletedUtc, diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 568e42b..4d0d4d4 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -8,6 +8,7 @@ signal state_changed signal request_failed(message: String) signal outcome_changed signal history_changed +signal sample_created var api_url := DEFAULT_API_URL var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes" @@ -59,6 +60,10 @@ func load_results_preview() -> void: _send("preview", HTTPClient.METHOD_GET, "/preview-results") +func create_sample_completed() -> void: + _send("sample_create", HTTPClient.METHOD_POST, "/sample-completed") + + func start_next_block() -> void: if simulation.is_empty(): return @@ -177,6 +182,9 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri history_next_before = "" if next_before == null else str(next_before) history_changed.emit() return + if action == "sample_create": + sample_created.emit() + return if action.begins_with("outcome_"): outcome = parsed outcome_changed.emit() diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 52242e9..70827bd 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -7,6 +7,7 @@ var list: VBoxContainer var status_label: Label var load_more_button: Button var pending_simulation_id := "" +var sample_button: Button func _ready() -> void: @@ -18,6 +19,8 @@ func _ready() -> void: ExamSimulationService.state_changed.connect(_on_simulation_loaded) if not ExamSimulationService.request_failed.is_connected(_on_request_failed): ExamSimulationService.request_failed.connect(_on_request_failed) + if not ExamSimulationService.sample_created.is_connected(_on_sample_created): + ExamSimulationService.sample_created.connect(_on_sample_created) var top := HBoxContainer.new() add_child(top) var back := Button.new() @@ -38,6 +41,14 @@ func _ready() -> void: status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(status_label, 16, Style.MUTED) add_child(status_label) + sample_button = Button.new() + sample_button.text = "Create Sample Completed Simulation" + sample_button.tooltip_text = "SUPER only; creates test review data without changing QBank statistics" + sample_button.visible = AuthState.role.to_upper() == "SUPER" + sample_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(sample_button, Style.AMBER) + sample_button.pressed.connect(_create_sample) + add_child(sample_button) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -95,7 +106,7 @@ func _summary_text(summary: Dictionary) -> String: var state := str(summary.get("state", "")) var date := str(summary.get("createdUtc", "")).left(10) if state == "completed": - return "%s | Completed\nPredicted %d (%d-%d) | %d/%d correct" % [date, 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))] + return "%s | %sCompleted\nPredicted %d (%d-%d) | %d/%d correct" % [date, "Sample | " if bool(summary.get("isSample", false)) else "", 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))] if state == "block_active": return "%s | Active\nResume Block %d of %d (timer continues)" % [date, int(summary.get("currentBlock", 0)) + 1, int(summary.get("totalBlocks", 12))] if state == "between_blocks": @@ -135,12 +146,28 @@ func _load_more() -> void: ExamSimulationService.load_history(ExamSimulationService.history_next_before) +func _create_sample() -> void: + sample_button.disabled = true + status_label.text = "Creating sample simulation..." + ExamSimulationService.create_sample_completed() + + +func _on_sample_created() -> void: + if not visible: + return + sample_button.disabled = false + status_label.text = "Sample created. Refreshing history..." + ExamSimulationService.load_history() + + func _on_request_failed(message: String) -> void: if not visible: return pending_simulation_id = "" status_label.text = message load_more_button.disabled = false + if sample_button != null: + sample_button.disabled = false static func _action_for_state(state: String) -> String: