extends GdUnitTestSuite ## NEO-24: GdUnit4 suite for `TargetSelectionClient`. Mirrors ## `position_authority_client_test.gd` — mock HTTP transport enqueues JSON responses and ## (optionally) tracks request count so the movement-triggered refresh cooldown is testable. const TargetSelectionTestDouble := preload("res://test/target_selection_test_double.gd") const ALPHA_ID := "prototype_target_alpha" const BETA_ID := "prototype_target_beta" # In-process transport: not an HTTPRequest subclass (Godot 4 will not call script `request()` # on those). Counts requests so "no lock → no GET" and "cooldown collapses snaps" are testable. class MockHttpTransport: extends Node signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) var request_count: int = 0 var last_url: String = "" var last_body: String = "" var last_method: HTTPClient.Method = HTTPClient.METHOD_GET var _queue: Array[Dictionary] = [] func enqueue(result: int, code: int, body: String) -> void: _queue.append({"result": result, "code": code, "body": body}) func request( url: String, _custom_headers: PackedStringArray = PackedStringArray(), method: HTTPClient.Method = HTTPClient.METHOD_GET, request_data: String = "" ) -> Error: request_count += 1 last_url = url last_method = method last_body = request_data if _queue.is_empty(): return ERR_UNAVAILABLE var r: Dictionary = _queue.pop_front() var body_bytes: PackedByteArray = str(r["body"]).to_utf8_buffer() request_completed.emit(r["result"], r["code"], PackedStringArray(), body_bytes) return OK func _make_client(http_transport: Node) -> Node: var c: Node = TargetSelectionTestDouble.new() c.set("injected_http", http_transport) auto_free(http_transport) auto_free(c) add_child(c) return c func _target_state_json(locked_id_or_null: Variant, validity: String, sequence: int = 0) -> String: var locked := "null" if locked_id_or_null is String and not (locked_id_or_null as String).is_empty(): locked = '"%s"' % (locked_id_or_null as String) return ( '{"schemaVersion":1,"playerId":"dev-local-1","lockedTargetId":%s,' % locked + '"validity":"%s","sequence":%d}' % [validity, sequence] ) func _select_response_json( applied: bool, locked_id_or_null: Variant, validity: String, sequence: int = 0, reason: String = "" ) -> String: var target_state := _target_state_json(locked_id_or_null, validity, sequence) if applied: return '{"schemaVersion":1,"selectionApplied":true,"targetState":%s}' % target_state var reason_segment := "" if not reason.is_empty(): reason_segment = ',"reasonCode":"%s"' % reason return ( '{"schemaVersion":1,"selectionApplied":false%s,"targetState":%s}' % [reason_segment, target_state] ) func test_sync_get_200_emits_state() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(null, "none")) var c := _make_client(transport) monitor_signals(c) # Act c.request_sync_from_server() # Assert await assert_signal(c).is_emitted("target_state_changed", any()) var state: Dictionary = c.cached_state() assert_that(state.get("validity", "")).is_equal("none") assert_that(state.get("lockedTargetId", "non-null-sentinel")).is_null() func test_tab_from_no_lock_selects_first_id() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) var c := _make_client(transport) monitor_signals(c) # Act c.request_tab_next() # Assert assert_that(transport.last_url).contains("/target/select") assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST) assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID) await assert_signal(c).is_emitted("target_state_changed", any()) assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_tab_from_alpha_requests_beta() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2) ) var c := _make_client(transport) # Act c.request_tab_next() c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID) assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID) func test_tab_wraps_from_beta_to_alpha() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 3) ) var c := _make_client(transport) # Act c.request_tab_next() c.request_tab_next() c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID) assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_denial_reflects_authoritative_target_state() -> void: # Arrange var transport := MockHttpTransport.new() # Server denies: selectionApplied=false, lockedTargetId=null, reasonCode=out_of_range. transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(false, null, "none", 0, "out_of_range") ) var c := _make_client(transport) # Act c.request_select_target_id(ALPHA_ID) # Assert var state: Dictionary = c.cached_state() assert_that(bool(state.get("selectionApplied", true))).is_false() assert_that(state.get("reasonCode", "")).is_equal("out_of_range") assert_that(state.get("lockedTargetId", "sentinel")).is_null() func test_clear_issues_post_without_target_id() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, null, "none", 4)) var c := _make_client(transport) # Act c.request_clear_target() # Assert assert_that(transport.last_url).contains("/target/select") assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST) assert_that(transport.last_body).not_contains("targetId") func test_authoritative_ack_while_locked_fires_refresh_get() -> void: # Arrange var transport := MockHttpTransport.new() # Step 1: lock alpha via POST. transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) # Step 2: movement-triggered GET shows out_of_range (soft lock). transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1) ) var c := _make_client(transport) # Act c.request_select_target_id(ALPHA_ID) var count_before_ack: int = transport.request_count c.on_authoritative_ack(Vector3.ZERO) # Assert assert_that(transport.request_count).is_equal(count_before_ack + 1) assert_that(c.cached_state().get("validity")).is_equal("out_of_range") assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_authoritative_ack_without_lock_fires_nothing() -> void: # Arrange var transport := MockHttpTransport.new() var c := _make_client(transport) # Act c.on_authoritative_ack(Vector3.ZERO) # Assert assert_that(transport.request_count).is_equal(0) func test_two_acks_within_cooldown_collapse_to_one_get() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(ALPHA_ID, "out_of_range", 1) ) var c := _make_client(transport) # Act c.request_select_target_id(ALPHA_ID) var count_before: int = transport.request_count c.on_authoritative_ack(Vector3.ZERO) c.on_authoritative_ack(Vector3.ZERO) # Assert assert_that(transport.request_count).is_equal(count_before + 1) # Authority stub that captures the last `submit_stream_targets` batch so we can verify # `TargetSelectionClient._post_select` kicks a freshness stream before POSTing. class FreshnessAuthorityStub: extends Node var last_batch: Array = [] var submit_count: int = 0 func submit_stream_targets(targets: Array) -> void: submit_count += 1 last_batch = targets.duplicate() func _make_player(pos: Vector3) -> Node3D: var n := Node3D.new() n.global_position = pos auto_free(n) add_child(n) return n func test_select_post_kicks_freshness_stream_before_posting() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) var player := _make_player(Vector3(1.5, 0.5, -2.25)) c.set_freshness_kick(authority, player) # Act c.request_select_target_id(ALPHA_ID) # Assert # Exactly one freshness submit, matching the pre-POST capsule position. assert_that(authority.submit_count).is_equal(1) assert_that(authority.last_batch.size()).is_equal(1) assert_that(authority.last_batch[0]).is_equal(Vector3(1.5, 0.5, -2.25)) # And the select POST still went out as usual. assert_that(transport.last_url).contains("/target/select") func test_select_post_includes_position_hint_when_player_wired() -> void: # Arrange # NEO-24 follow-up #5: the select POST body must carry the live capsule position as # `positionHint` so the server's range check can bypass any stale stored snap. var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 1) ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) # Values chosen so `JSON.stringify` emits exact decimal representations (no float precision # drift): 3.0 → "3", 0.5 → "0.5", 5.0 → "5". Re-parse the JSON for precise assertions. var player := _make_player(Vector3(3.0, 0.5, 5.0)) c.set_freshness_kick(authority, player) # Act c.request_select_target_id(BETA_ID) # Assert var parsed: Variant = JSON.parse_string(transport.last_body) assert_that(parsed is Dictionary).is_true() var body: Dictionary = parsed assert_that(body.get("targetId")).is_equal(BETA_ID) assert_that(body.has("positionHint")).is_true() var hint: Dictionary = body["positionHint"] assert_float(hint.get("x")).is_equal_approx(3.0, 0.0001) assert_float(hint.get("y")).is_equal_approx(0.5, 0.0001) assert_float(hint.get("z")).is_equal_approx(5.0, 0.0001) func test_select_post_without_freshness_wiring_is_a_noop() -> void: # Arrange # Regression: tests that construct the client without `set_freshness_kick(...)` must # still POST normally, *without* a `positionHint` (hint is opt-in per wiring). var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) var c := _make_client(transport) # Act c.request_select_target_id(ALPHA_ID) # Assert assert_that(transport.last_url).contains("/target/select") # No `positionHint` key in the body when no player is wired — re-parse so the assertion # is robust against JSON whitespace / key ordering. var parsed: Variant = JSON.parse_string(transport.last_body) assert_that(parsed is Dictionary).is_true() assert_that((parsed as Dictionary).has("positionHint")).is_false() assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_tab_from_no_lock_skips_out_of_range_and_picks_beta() -> void: # Arrange # Player stands next to beta (well outside alpha's ring). Without the range-aware # tab cycle the server denies with `out_of_range` on alpha — see NEO-24 follow-up #3. var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 1) ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) # Near beta anchor (3, 3), ~1.4 m horizontal — inside 6 m. Far from alpha at (-3, -3). var player := _make_player(Vector3(4.0, 0.5, 4.0)) c.set_freshness_kick(authority, player) # Act c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID) assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID) func test_tab_from_no_lock_with_nothing_in_range_falls_back_to_first_id() -> void: # Arrange # No anchors are in range → cycle-order fallback. Server owns the denial reason. var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(false, null, "none", 0, "out_of_range") ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) var player := _make_player(Vector3(20.0, 0.5, 20.0)) c.set_freshness_kick(authority, player) # Act c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID) assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range") func test_tab_from_locked_alpha_at_origin_swaps_to_beta_when_both_in_range() -> void: # Arrange # With alpha (-3,-3) and beta (3,3) sharing r=6, origin is in both rings. Locked to # alpha, Tab should request beta (normal cycle behavior; range-aware skip-current # does not interfere when the next id is also in range). var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, BETA_ID, "ok", 2) ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) var player := _make_player(Vector3(0.0, 0.5, 0.0)) c.set_freshness_kick(authority, player) # Act c.request_tab_next() c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID) assert_that(c.cached_state().get("lockedTargetId")).is_equal(BETA_ID) func test_tab_when_only_current_lock_is_in_range_falls_back_to_cycle_for_denial() -> void: # Arrange # Locked to alpha, only alpha is in range — Tab should express a *swap* intent and # fall through to the plain cycle so the server denies with `out_of_range` (NEO-23 # soft-lock rule). If the picker returned alpha instead, the user would see no # visible reaction to Tab. var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(false, ALPHA_ID, "ok", 1, "out_of_range") ) var c := _make_client(transport) var authority := FreshnessAuthorityStub.new() auto_free(authority) add_child(authority) # Near alpha (-3, -3), ~1.4 m in — beta (3, 3) is ~8.5 m out. var player := _make_player(Vector3(-2.0, 0.5, -2.0)) c.set_freshness_kick(authority, player) # Act c.request_select_target_id(ALPHA_ID) c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % BETA_ID) assert_that(c.cached_state().get("reasonCode", "")).is_equal("out_of_range") assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_tab_without_player_ref_still_uses_plain_cycle_order() -> void: # Arrange # Regression: headless unit tests and any client that skips `set_freshness_kick(...)` # must keep the pre-existing cycle-order semantics. Alpha is requested from no-lock. var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) var c := _make_client(transport) # Act c.request_tab_next() # Assert assert_that(transport.last_body).contains('"targetId":"%s"' % ALPHA_ID)