mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 02:32:04 +00:00
test(multiplayer): cover generic mutation retry recovery
Closes most of §8.43's 'decline, regional outage retry UI, failed
reconnect, duplicate-action recovery beyond proposals' remaining
list -- turned out to be mostly stale doc, not missing code.
matchmaking.gd's decline button/handler already existed
(%DeclineButton, _on_decline_pressed, visibility toggled by
MatchmakingState.PROPOSED alongside accept). ControlPlaneClient's
can_retry_last_mutation()/retry_last_mutation() -- the generic
'duplicate-action recovery beyond proposals' and 'regional outage
retry' mechanism -- also already existed: any mutation (not just a
proposal response) becomes retryable on a transport failure or a
408/429/503 response, and matchmaking.gd's queue button already fell
back to it ('Retry Request'). Neither had any test coverage proving
the mechanism actually works for a non-proposal mutation --
is_retryable_mutation_response's pure classification was the only
thing tested.
Two new tests: test_generic_mutation_retry_recovers_after_a_transient_failure
proves can_retry_last_mutation() transitions from false (mutation
in flight) to true after a transport-level failure on an ordinary
queue_heartbeat, exactly the 'regional outage' case; test_generic_mutation_retry_is_not_offered_for_unsafe_failures
proves a 409 (revision conflict) is never offered as a blind retry
and that retry_last_mutation() fails closed with ERR_INVALID_DATA
rather than resending a stale mutation. retry_last_mutation's literal
network dispatch (HTTPRequest.request()) is not exercised -- it needs
a live SceneTree that test_runner.tscn's synchronous single-_ready()
execution model cannot provide mid-suite; the two tests cover the
can_retry_last_mutation() decision boundary and the fail-closed path
instead, which is what's actually new here.
Verified against the real Godot 4.7.1 binary now that headless
testing has resumed: test_runner.tscn 214/214 clean (no crash, no
engine-level error), full make verify-multiplayer-local re-run clean,
zero new crash reports.
Remaining in §8.43: version-mismatch-specific messaging (a protocol
rejection currently surfaces only as the server's generic error
string), failed-reconnect UX, and §8.16's arena selection/long-running
worker integration.
This commit is contained in:
@@ -181,6 +181,56 @@ func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
|
||||
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
|
||||
|
||||
|
||||
# multiplayer-next.md 8.43 named "duplicate-action recovery beyond proposals"
|
||||
# and "regional outage retry UI" as remaining. Both mechanisms (can_retry_last_mutation /
|
||||
# retry_last_mutation, and matchmaking.gd's queue button falling back to them)
|
||||
# already existed in the client, but had no test coverage proving the
|
||||
# generic (non-proposal) mutation path actually recovers end to end -- only
|
||||
# is_retryable_mutation_response's pure classification was covered above.
|
||||
func test_generic_mutation_retry_recovers_after_a_transient_failure() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
assert_true(client.state.begin_queue("ticket-retry-generic", "casual"), "queue setup succeeds")
|
||||
# Simulate what _start_request itself would already have recorded before
|
||||
# a real network call was in flight, the same way the pre-existing
|
||||
# conflict-handler tests above set _operation directly.
|
||||
client._operation = "queue_heartbeat"
|
||||
client._last_mutation = {"operation": "queue_heartbeat", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-generic/heartbeat", "payload": {"revision": 0}, "key": "heartbeat-retry-key-123456", "expected_revision": 0}
|
||||
assert_true(not client.can_retry_last_mutation(), "a mutation still in flight is never offered as retryable")
|
||||
|
||||
# A regional outage: the transport itself failed rather than returning a
|
||||
# decoded HTTP status -- exactly the "regional outage retry" case. This
|
||||
# transition is the actual previously-uncovered boundary: nothing tested
|
||||
# that a generic (non-proposal) mutation ever becomes retryable at all,
|
||||
# only is_retryable_mutation_response's pure classification above.
|
||||
# retry_last_mutation's own dispatch is not exercised here: it reaches
|
||||
# HTTPRequest.request(), which needs the node inside a live SceneTree,
|
||||
# and test_runner.tscn runs every test method from within its own
|
||||
# _ready() while the tree is still being built, so that is out of reach
|
||||
# for this harness -- the "not offered at all" boundary below covers the
|
||||
# part of retry_last_mutation this environment can exercise safely.
|
||||
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
|
||||
assert_true(client.can_retry_last_mutation(), "a transport failure on a non-proposal mutation is offered as retryable")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_generic_mutation_retry_is_not_offered_for_unsafe_failures() -> void:
|
||||
var client := ControlPlaneClient.new()
|
||||
client._ready()
|
||||
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "client configures")
|
||||
assert_true(client.state.begin_queue("ticket-retry-unsafe", "casual"), "queue setup succeeds")
|
||||
client._operation = "queue_cancel"
|
||||
client._last_mutation = {"operation": "queue_cancel", "method": HTTPClient.METHOD_POST, "path": "/v1/queue/ticket-retry-unsafe/cancel", "payload": {}, "key": "cancel-retry-key-123456", "expected_revision": 0}
|
||||
# A 409 is a revision/idempotency conflict, not a transient failure --
|
||||
# should_recover_queue_after_conflict owns recovering it instead, and a
|
||||
# blind resend would replay a mutation whose precondition already failed.
|
||||
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
|
||||
assert_true(not client.can_retry_last_mutation(), "a conflict response is never offered as a blind retry")
|
||||
assert_eq(client.retry_last_mutation(), ERR_INVALID_DATA, "retrying when not offered fails closed rather than resending a stale mutation")
|
||||
client.free()
|
||||
|
||||
|
||||
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
|
||||
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
|
||||
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
|
||||
|
||||
Reference in New Issue
Block a user