Make simulation block submission idempotent

This commit is contained in:
hz
2026-07-30 15:30:54 -05:00
parent d5048c155a
commit ae6b37eb7d
5 changed files with 29 additions and 0 deletions
@@ -111,6 +111,16 @@ public sealed class ExamSimulationTests
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); 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<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) => private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{ {
@@ -354,6 +354,8 @@ public class ExamSimulationStore
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false) public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
{ {
if (IsAlreadySubmittedState(simulation.State))
return simulation;
if (simulation.State != ExamSimulationStates.BlockActive) if (simulation.State != ExamSimulationStates.BlockActive)
throw new InvalidOperationException("There is no active block to submit."); throw new InvalidOperationException("There is no active block to submit.");
if (!expired && simulation.BlockDeadlineUtc is not null) if (!expired && simulation.BlockDeadlineUtc is not null)
@@ -370,6 +372,9 @@ public class ExamSimulationStore
return simulation; return simulation;
} }
public static bool IsAlreadySubmittedState(string state) =>
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
public async Task AbandonAsync(ExamSimulation simulation) public async Task AbandonAsync(ExamSimulation simulation)
{ {
simulation.State = ExamSimulationStates.Abandoned; simulation.State = ExamSimulationStates.Abandoned;
@@ -154,6 +154,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
if response_code == 401: if response_code == 401:
AuthState.handle_unauthorized(request_access_token) AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: 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 server_message := body.get_string_from_utf8().strip_edges()
var error_parsed: Variant = JSON.parse_string(server_message) var error_parsed: Variant = JSON.parse_string(server_message)
if typeof(error_parsed) == TYPE_DICTIONARY: if typeof(error_parsed) == TYPE_DICTIONARY:
@@ -205,6 +207,10 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
state_changed.emit() 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: func _process_request_queue() -> void:
if is_loading or request_queue.is_empty(): if is_loading or request_queue.is_empty():
return return
+3
View File
@@ -1190,6 +1190,9 @@ func _on_simulation_state_changed() -> void:
func _on_simulation_request_failed(message: String) -> void: func _on_simulation_request_failed(message: String) -> void:
if BlockSessionService.session_kind != "simulation": if BlockSessionService.session_kind != "simulation":
return return
if str(ExamSimulationService.simulation.get("state", "")) in ["between_blocks", "completed"]:
simulation_submit_pending = false
return
simulation_submit_pending = false simulation_submit_pending = false
if ExamSimulationService.last_failed_action != "submit": if ExamSimulationService.last_failed_action != "submit":
# A per-question autosave failing shouldn't interrupt a timed exam with a # A per-question autosave failing shouldn't interrupt a timed exam with a
+5
View File
@@ -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.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 = [{"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" 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() var host := Control.new()
host.size = Vector2(1400, 800) host.size = Vector2(1400, 800)
root.add_child(host) root.add_child(host)