Lock submitted simulation answers
This commit is contained in:
@@ -219,6 +219,48 @@ public sealed class ExamSimulationTests
|
|||||||
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
|
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<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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class ExamSimulationResponse
|
|||||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||||
public bool Flagged { get; set; }
|
public bool Flagged { get; set; }
|
||||||
public int ElapsedSeconds { get; set; }
|
public int ElapsedSeconds { get; set; }
|
||||||
|
public bool? IsSubmitted { get; set; }
|
||||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ public class SaveSimulationResponseRequest
|
|||||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||||
public bool Flagged { get; set; }
|
public bool Flagged { get; set; }
|
||||||
public int ElapsedSeconds { get; set; }
|
public int ElapsedSeconds { get; set; }
|
||||||
|
public bool IsSubmitted { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExamSimulationQuestionResponse
|
public class ExamSimulationQuestionResponse
|
||||||
|
|||||||
@@ -348,21 +348,49 @@ public class ExamSimulationStore
|
|||||||
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
|
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
|
||||||
throw new ArgumentException("Choice is not presented for this question.");
|
throw new ArgumentException("Choice is not presented for this question.");
|
||||||
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId);
|
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)
|
if (response is null)
|
||||||
{
|
{
|
||||||
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
|
response = new ExamSimulationResponse
|
||||||
|
{
|
||||||
|
QuestionExternalId = request.QuestionExternalId,
|
||||||
|
IsSubmitted = false
|
||||||
|
};
|
||||||
simulation.Responses.Add(response);
|
simulation.Responses.Add(response);
|
||||||
}
|
}
|
||||||
response.ChoiceId = request.ChoiceId;
|
response.ChoiceId = request.ChoiceId;
|
||||||
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
|
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
|
||||||
response.Flagged = request.Flagged;
|
response.Flagged = request.Flagged;
|
||||||
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
|
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
|
||||||
|
response.IsSubmitted = request.IsSubmitted;
|
||||||
response.UpdatedUtc = DateTime.UtcNow;
|
response.UpdatedUtc = DateTime.UtcNow;
|
||||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||||
await ReplaceAsync(simulation);
|
await ReplaceAsync(simulation);
|
||||||
return 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<string> 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<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
|
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
|
||||||
{
|
{
|
||||||
if (IsAlreadySubmittedState(simulation.State))
|
if (IsAlreadySubmittedState(simulation.State))
|
||||||
|
|||||||
@@ -256,18 +256,26 @@ func restore_simulation_responses(saved_responses: Array) -> void:
|
|||||||
"is_correct": false,
|
"is_correct": false,
|
||||||
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
||||||
"flagged": bool(response.get("flagged", false)),
|
"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
|
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:
|
func get_current_question() -> Dictionary:
|
||||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||||
return {}
|
return {}
|
||||||
return active_questions[current_index]
|
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()
|
var question := get_current_question()
|
||||||
if question.is_empty():
|
if question.is_empty():
|
||||||
return {}
|
return {}
|
||||||
@@ -284,6 +292,7 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict
|
|||||||
answers[current_index] = answer
|
answers[current_index] = answer
|
||||||
if session_kind == "simulation":
|
if session_kind == "simulation":
|
||||||
answer["is_correct"] = false
|
answer["is_correct"] = false
|
||||||
|
answer["is_submitted"] = is_submitted
|
||||||
answers[current_index] = answer
|
answers[current_index] = answer
|
||||||
session_changed.emit()
|
session_changed.emit()
|
||||||
return answer
|
return answer
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ var pending_action := ""
|
|||||||
var request_queue: Array = []
|
var request_queue: Array = []
|
||||||
var request_access_token := ""
|
var request_access_token := ""
|
||||||
var last_failed_action := ""
|
var last_failed_action := ""
|
||||||
|
var submit_recovery_attempts := 0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -69,7 +70,7 @@ func start_next_block() -> void:
|
|||||||
_send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", "")))
|
_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():
|
if simulation.is_empty():
|
||||||
return
|
return
|
||||||
_send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), {
|
_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,
|
"choiceId": choice_id,
|
||||||
"eliminatedChoiceIds": eliminated,
|
"eliminatedChoiceIds": eliminated,
|
||||||
"flagged": flagged,
|
"flagged": flagged,
|
||||||
"elapsedSeconds": elapsed_seconds
|
"elapsedSeconds": elapsed_seconds,
|
||||||
|
"isSubmitted": is_submitted
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
func submit_block() -> void:
|
func submit_block() -> void:
|
||||||
if simulation.is_empty():
|
if simulation.is_empty():
|
||||||
return
|
return
|
||||||
|
submit_recovery_attempts = 0
|
||||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
_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 result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
if _is_stale_submit_failure(action):
|
if _is_stale_submit_failure(action):
|
||||||
return
|
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 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,12 +215,27 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
|||||||
return
|
return
|
||||||
simulation = _normalize_simulation(parsed)
|
simulation = _normalize_simulation(parsed)
|
||||||
state_changed.emit()
|
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:
|
func _is_stale_submit_failure(action: String) -> bool:
|
||||||
return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"]
|
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:
|
func _process_request_queue() -> void:
|
||||||
if is_loading or request_queue.is_empty():
|
if is_loading or request_queue.is_empty():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1163,19 +1163,22 @@ func on_hide() -> void:
|
|||||||
slot_machine_panel.close_panel()
|
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":
|
if BlockSessionService.session_kind != "simulation":
|
||||||
return
|
return
|
||||||
|
if bool(current_answer.get("is_submitted", false)) and not is_submitted:
|
||||||
|
return
|
||||||
var question := BlockSessionService.get_current_question()
|
var question := BlockSessionService.get_current_question()
|
||||||
if question.is_empty():
|
if question.is_empty():
|
||||||
return
|
return
|
||||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged, is_submitted)
|
||||||
ExamSimulationService.save_response(
|
ExamSimulationService.save_response(
|
||||||
str(question.get("id", "")),
|
str(question.get("id", "")),
|
||||||
selected_choice,
|
selected_choice,
|
||||||
eliminated,
|
eliminated,
|
||||||
flagged,
|
flagged,
|
||||||
int(BlockSessionService.get_current_elapsed_seconds()))
|
int(BlockSessionService.get_current_elapsed_seconds()),
|
||||||
|
is_submitted)
|
||||||
|
|
||||||
|
|
||||||
func _on_simulation_state_changed() -> void:
|
func _on_simulation_state_changed() -> void:
|
||||||
@@ -1577,7 +1580,7 @@ func _toggle_eliminate_selected() -> void:
|
|||||||
|
|
||||||
func _submit_or_next() -> void:
|
func _submit_or_next() -> void:
|
||||||
if BlockSessionService.session_kind == "simulation":
|
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():
|
if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size():
|
||||||
BlockSessionService.move_next()
|
BlockSessionService.move_next()
|
||||||
_load_question()
|
_load_question()
|
||||||
@@ -3373,7 +3376,9 @@ func _apply_saved_choice_state() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _is_correct_answer_locked() -> bool:
|
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:
|
func _refresh_submit_state() -> void:
|
||||||
|
|||||||
@@ -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.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")
|
_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
|
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"]:
|
for session_kind in ["block", "core", "bookmark", "review", "simulation"]:
|
||||||
root.get_node("BlockSessionService").session_kind = session_kind
|
root.get_node("BlockSessionService").session_kind = session_kind
|
||||||
player._refresh_toolbar_state()
|
player._refresh_toolbar_state()
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ func _run() -> void:
|
|||||||
service.simulation["state"] = "completed"
|
service.simulation["state"] = "completed"
|
||||||
_assert(service._is_stale_submit_failure("submit"), "a submit failure after completion should be ignored")
|
_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")
|
_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"
|
service.simulation["state"] = "between_blocks"
|
||||||
var host := Control.new()
|
var host := Control.new()
|
||||||
host.size = Vector2(1400, 800)
|
host.size = Vector2(1400, 800)
|
||||||
|
|||||||
Reference in New Issue
Block a user