From ae6b37eb7d8504574ecec6447d5fad2eab410bbe Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 30 Jul 2026 15:30:43 -0500 Subject: [PATCH] Make simulation block submission idempotent --- api/QuestionsApi.Tests/ExamSimulationTests.cs | 10 ++++++++++ api/QuestionsApi/Services/ExamSimulationStore.cs | 5 +++++ scripts/autoload/exam_simulation_service.gd | 6 ++++++ scripts/ui/question_player.gd | 3 +++ tests/simulation_history_ui_test.gd | 5 +++++ 5 files changed, 29 insertions(+) diff --git a/api/QuestionsApi.Tests/ExamSimulationTests.cs b/api/QuestionsApi.Tests/ExamSimulationTests.cs index 91494cd..5713108 100644 --- a/api/QuestionsApi.Tests/ExamSimulationTests.cs +++ b/api/QuestionsApi.Tests/ExamSimulationTests.cs @@ -111,6 +111,16 @@ public sealed class ExamSimulationTests Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); } + [Theory] + [InlineData(ExamSimulationStates.BetweenBlocks, true)] + [InlineData(ExamSimulationStates.Completed, true)] + [InlineData(ExamSimulationStates.BlockActive, false)] + [InlineData(ExamSimulationStates.Abandoned, false)] + public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected) + { + Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state)); + } + private static List BuildItems(int count, bool wasSeen = false) => Enumerable.Range(0, count).Select(index => new ExamSimulationItem { diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 037ab15..356a5ae 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -354,6 +354,8 @@ public class ExamSimulationStore public async Task SubmitBlockAsync(ExamSimulation simulation, bool expired = false) { + if (IsAlreadySubmittedState(simulation.State)) + return simulation; if (simulation.State != ExamSimulationStates.BlockActive) throw new InvalidOperationException("There is no active block to submit."); if (!expired && simulation.BlockDeadlineUtc is not null) @@ -370,6 +372,9 @@ public class ExamSimulationStore return simulation; } + public static bool IsAlreadySubmittedState(string state) => + state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed; + public async Task AbandonAsync(ExamSimulation simulation) { simulation.State = ExamSimulationStates.Abandoned; diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 741693b..4933dbf 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -154,6 +154,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri if response_code == 401: AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + if _is_stale_submit_failure(action): + return var server_message := body.get_string_from_utf8().strip_edges() var error_parsed: Variant = JSON.parse_string(server_message) if typeof(error_parsed) == TYPE_DICTIONARY: @@ -205,6 +207,10 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri state_changed.emit() +func _is_stale_submit_failure(action: String) -> bool: + return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"] + + func _process_request_queue() -> void: if is_loading or request_queue.is_empty(): return diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index a61e1cc..76d1b54 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1190,6 +1190,9 @@ func _on_simulation_state_changed() -> void: func _on_simulation_request_failed(message: String) -> void: if BlockSessionService.session_kind != "simulation": return + if str(ExamSimulationService.simulation.get("state", "")) in ["between_blocks", "completed"]: + simulation_submit_pending = false + return simulation_submit_pending = false if ExamSimulationService.last_failed_action != "submit": # A per-question autosave failing shouldn't interrupt a timed exam with a diff --git a/tests/simulation_history_ui_test.gd b/tests/simulation_history_ui_test.gd index 18ce497..6736d6a 100644 --- a/tests/simulation_history_ui_test.gd +++ b/tests/simulation_history_ui_test.gd @@ -15,6 +15,11 @@ func _run() -> void: service.simulation = {"id": "active", "state": "between_blocks", "currentBlock": 2, "totalBlocks": 12, "createdUtc": "2026-07-16T22:04:00Z"} service.history = [{"id": "completed", "state": "completed", "currentBlock": 11, "totalBlocks": 12, "createdUtc": "2026-07-15T20:35:00Z", "predictedScore": 236, "rangeLow": 221, "rangeHigh": 248, "passProbability": 0.82}] service.history_next_cursor = "next-page" + _assert(service._is_stale_submit_failure("submit"), "a submit failure after reaching between-block state should be ignored") + service.simulation["state"] = "completed" + _assert(service._is_stale_submit_failure("submit"), "a submit failure after completion should be ignored") + _assert(not service._is_stale_submit_failure("save"), "non-submit failures should still be surfaced") + service.simulation["state"] = "between_blocks" var host := Control.new() host.size = Vector2(1400, 800) root.add_child(host)