From 5488dec2c772297cdc41ddea939179b918e84368 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 30 Jul 2026 16:54:11 -0500 Subject: [PATCH] Lock submitted simulation answers --- api/QuestionsApi.Tests/ExamSimulationTests.cs | 42 +++++++++++++++++++ api/QuestionsApi/Models/ExamSimulation.cs | 2 + .../Services/ExamSimulationStore.cs | 30 ++++++++++++- scripts/autoload/block_session_service.gd | 13 +++++- scripts/autoload/exam_simulation_service.gd | 29 ++++++++++++- scripts/ui/question_player.gd | 15 ++++--- tests/post_answer_review_test.gd | 14 +++++++ tests/simulation_history_ui_test.gd | 7 ++++ 8 files changed, 142 insertions(+), 10 deletions(-) diff --git a/api/QuestionsApi.Tests/ExamSimulationTests.cs b/api/QuestionsApi.Tests/ExamSimulationTests.cs index f51b7bb..b6d3b3f 100644 --- a/api/QuestionsApi.Tests/ExamSimulationTests.cs +++ b/api/QuestionsApi.Tests/ExamSimulationTests.cs @@ -219,6 +219,48 @@ public sealed class ExamSimulationTests Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state)); } + [Fact] + public void SubmittedSimulationResponsesAreLocked() + { + Assert.True(ExamSimulationStore.IsResponseLocked( + new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true })); + Assert.False(ExamSimulationStore.IsResponseLocked( + new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false })); + Assert.True(ExamSimulationStore.IsResponseLocked( + new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null })); + Assert.False(ExamSimulationStore.IsResponseLocked( + new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null })); + } + + [Fact] + public void SubmittedResponseRetriesMustPreserveAnswerContent() + { + var response = new ExamSimulationResponse + { + ChoiceId = "B", + EliminatedChoiceIds = ["A"], + Flagged = true, + IsSubmitted = true + }; + var same = new SaveSimulationResponseRequest + { + ChoiceId = "B", + EliminatedChoiceIds = ["A"], + Flagged = true, + IsSubmitted = true + }; + var changed = new SaveSimulationResponseRequest + { + ChoiceId = "C", + EliminatedChoiceIds = ["A"], + Flagged = true, + IsSubmitted = true + }; + + Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"])); + Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"])); + } + private static List BuildItems(int count, bool wasSeen = false) => Enumerable.Range(0, count).Select(index => new ExamSimulationItem { diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 5a2d8b1..4dcbff3 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -53,6 +53,7 @@ public class ExamSimulationResponse public List EliminatedChoiceIds { get; set; } = []; public bool Flagged { get; set; } public int ElapsedSeconds { get; set; } + public bool? IsSubmitted { get; set; } public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -84,6 +85,7 @@ public class SaveSimulationResponseRequest public List EliminatedChoiceIds { get; set; } = []; public bool Flagged { get; set; } public int ElapsedSeconds { get; set; } + public bool IsSubmitted { get; set; } } public class ExamSimulationQuestionResponse diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index d1bd2bf..9f2dedd 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -348,21 +348,49 @@ public class ExamSimulationStore if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "") throw new ArgumentException("Choice is not presented for this question."); var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId); + if (response is not null && IsResponseLocked(response)) + { + if (ResponseContentMatches(response, request, item.PresentedChoiceIds)) + return simulation; + throw new InvalidOperationException("A submitted simulation answer cannot be changed."); + } if (response is null) { - response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId }; + response = new ExamSimulationResponse + { + QuestionExternalId = request.QuestionExternalId, + IsSubmitted = false + }; simulation.Responses.Add(response); } response.ChoiceId = request.ChoiceId; response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList(); response.Flagged = request.Flagged; response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800); + response.IsSubmitted = request.IsSubmitted; response.UpdatedUtc = DateTime.UtcNow; simulation.UpdatedUtc = DateTime.UtcNow; await ReplaceAsync(simulation); return simulation; } + public static bool IsResponseLocked(ExamSimulationResponse response) => + response.IsSubmitted == true || + (response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId)); + + public static bool ResponseContentMatches( + ExamSimulationResponse response, + SaveSimulationResponseRequest request, + IReadOnlyCollection presentedChoiceIds) + { + var eliminated = request.EliminatedChoiceIds + .Where(presentedChoiceIds.Contains) + .ToHashSet(StringComparer.Ordinal); + return response.ChoiceId == request.ChoiceId && + response.Flagged == request.Flagged && + response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated); + } + public async Task SubmitBlockAsync(ExamSimulation simulation, bool expired = false) { if (IsAlreadySubmittedState(simulation.State)) diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 36b7451..82f5e28 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -256,18 +256,26 @@ func restore_simulation_responses(saved_responses: Array) -> void: "is_correct": false, "eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(), "flagged": bool(response.get("flagged", false)), - "elapsed_seconds": int(response.get("elapsedSeconds", 0)) + "elapsed_seconds": int(response.get("elapsedSeconds", 0)), + "is_submitted": _simulation_response_is_submitted(response) } break +func _simulation_response_is_submitted(response: Dictionary) -> bool: + var submitted: Variant = response.get("isSubmitted", null) + if submitted == null: + return not str(response.get("choiceId", "")).is_empty() + return bool(submitted) + + func get_current_question() -> Dictionary: if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): return {} return active_questions[current_index] -func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary: +func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> Dictionary: var question := get_current_question() if question.is_empty(): return {} @@ -284,6 +292,7 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict answers[current_index] = answer if session_kind == "simulation": answer["is_correct"] = false + answer["is_submitted"] = is_submitted answers[current_index] = answer session_changed.emit() return answer diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 4933dbf..cb328fb 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -24,6 +24,7 @@ var pending_action := "" var request_queue: Array = [] var request_access_token := "" var last_failed_action := "" +var submit_recovery_attempts := 0 func _ready() -> void: @@ -69,7 +70,7 @@ func start_next_block() -> void: _send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", ""))) -func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int) -> void: +func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int, is_submitted := false) -> void: if simulation.is_empty(): return _send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), { @@ -77,13 +78,15 @@ func save_response(question_id: String, choice_id: String, eliminated: Array, fl "choiceId": choice_id, "eliminatedChoiceIds": eliminated, "flagged": flagged, - "elapsedSeconds": elapsed_seconds + "elapsedSeconds": elapsed_seconds, + "isSubmitted": is_submitted }) func submit_block() -> void: if simulation.is_empty(): return + submit_recovery_attempts = 0 _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) @@ -156,6 +159,13 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: if _is_stale_submit_failure(action): return + if _should_reconcile_submit_failure(action, response_code): + submit_recovery_attempts += 1 + _send( + "submit_reconcile", + HTTPClient.METHOD_GET, + "/%s" % str(simulation.get("id", "")).uri_encode()) + 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,12 +215,27 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri return simulation = _normalize_simulation(parsed) state_changed.emit() + if action == "submit": + submit_recovery_attempts = 0 + elif action == "submit_reconcile": + if str(simulation.get("state", "")) == "block_active": + _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) + else: + submit_recovery_attempts = 0 func _is_stale_submit_failure(action: String) -> bool: return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"] +func _should_reconcile_submit_failure(action: String, response_code: int) -> bool: + return ( + action == "submit" and + response_code == 409 and + submit_recovery_attempts == 0 and + str(simulation.get("state", "")) == "block_active") + + 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 48ad7d9..0d45f34 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1163,19 +1163,22 @@ func on_hide() -> void: slot_machine_panel.close_panel() -func _save_simulation_current_response() -> void: +func _save_simulation_current_response(is_submitted := false) -> void: if BlockSessionService.session_kind != "simulation": return + if bool(current_answer.get("is_submitted", false)) and not is_submitted: + return var question := BlockSessionService.get_current_question() if question.is_empty(): return - current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) + current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged, is_submitted) ExamSimulationService.save_response( str(question.get("id", "")), selected_choice, eliminated, flagged, - int(BlockSessionService.get_current_elapsed_seconds())) + int(BlockSessionService.get_current_elapsed_seconds()), + is_submitted) func _on_simulation_state_changed() -> void: @@ -1577,7 +1580,7 @@ func _toggle_eliminate_selected() -> void: func _submit_or_next() -> void: if BlockSessionService.session_kind == "simulation": - _save_simulation_current_response() + _save_simulation_current_response(not selected_choice.is_empty()) if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size(): BlockSessionService.move_next() _load_question() @@ -3373,7 +3376,9 @@ func _apply_saved_choice_state() -> void: func _is_correct_answer_locked() -> bool: - return bool(current_answer.get("is_correct", false)) + return bool(current_answer.get("is_correct", false)) or ( + BlockSessionService.session_kind == "simulation" and + bool(current_answer.get("is_submitted", false))) func _refresh_submit_state() -> void: diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 2ab85b0..460d1f8 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -426,6 +426,20 @@ func _test_player_runtime_construction() -> void: _assert(player.choice_buttons[0].size.x <= player.question_panel.size.x, "long answer choices should not widen the question player") _assert(player.next_button.get_global_rect().end.x <= player.get_global_rect().end.x + 0.5, "long answer choices should never push Submit beyond the viewport") var prior_session_kind: String = root.get_node("BlockSessionService").session_kind + _assert(root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": null}), "legacy answered simulation responses should restore as submitted") + _assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": false}), "new simulation drafts should remain editable after an in-session autosave") + _assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "", "isSubmitted": null}), "legacy skipped simulation responses should remain revisitable") + root.get_node("BlockSessionService").session_kind = "simulation" + player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}] + player.current_answer = {"selected_choice_id": "A", "is_submitted": true} + player.selected_choice = "A" + player._apply_saved_choice_state() + _assert(player.choice_buttons[0].disabled and player.choice_buttons[1].disabled, "resumed submitted simulation answers should lock every choice") + _assert(not player.next_button.disabled, "a resumed submitted answer should still allow navigation to the next question") + player._select_choice(1) + _assert(player.selected_choice == "A", "a resumed submitted simulation answer should not be changeable") + player.current_answer = {} + player.selected_choice = "" for session_kind in ["block", "core", "bookmark", "review", "simulation"]: root.get_node("BlockSessionService").session_kind = session_kind player._refresh_toolbar_state() diff --git a/tests/simulation_history_ui_test.gd b/tests/simulation_history_ui_test.gd index 6736d6a..f6ef39f 100644 --- a/tests/simulation_history_ui_test.gd +++ b/tests/simulation_history_ui_test.gd @@ -19,6 +19,13 @@ func _run() -> void: 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"] = "block_active" + service.submit_recovery_attempts = 0 + _assert(service._should_reconcile_submit_failure("submit", 409), "an active-block submit conflict should reconcile automatically") + service.submit_recovery_attempts = 1 + _assert(not service._should_reconcile_submit_failure("submit", 409), "submit conflict recovery should be bounded") + _assert(not service._should_reconcile_submit_failure("submit", 500), "non-conflict submit failures should still be surfaced") + service.submit_recovery_attempts = 0 service.simulation["state"] = "between_blocks" var host := Control.new() host.size = Vector2(1400, 800)