Make simulation block submission idempotent
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user