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: var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _target_state_json(null, "none")) var c := _make_client(transport) monitor_signals(c) c.request_sync_from_server() assert_signal(c).is_emitted("target_state_changed") 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: 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) c.request_tab_next() 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) assert_signal(c).is_emitted("target_state_changed") assert_that(c.cached_state().get("lockedTargetId")).is_equal(ALPHA_ID) func test_tab_from_alpha_requests_beta() -> void: 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) c.request_tab_next() c.request_tab_next() 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: 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) c.request_tab_next() c.request_tab_next() c.request_tab_next() 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: 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) c.request_select_target_id(ALPHA_ID) 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: var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, null, "none", 4)) var c := _make_client(transport) c.request_clear_target() 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_position_snap_while_locked_fires_refresh_get() -> void: 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) c.request_select_target_id(ALPHA_ID) var count_before_snap: int = transport.request_count c.on_authoritative_position_snap(Vector3.ZERO, true) assert_that(transport.request_count).is_equal(count_before_snap + 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_position_snap_without_lock_fires_nothing() -> void: var transport := MockHttpTransport.new() var c := _make_client(transport) c.on_authoritative_position_snap(Vector3.ZERO, true) assert_that(transport.request_count).is_equal(0) func test_two_snaps_within_cooldown_collapse_to_one_get() -> void: 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) c.request_select_target_id(ALPHA_ID) var count_before: int = transport.request_count c.on_authoritative_position_snap(Vector3.ZERO, true) c.on_authoritative_position_snap(Vector3.ZERO, true) assert_that(transport.request_count).is_equal(count_before + 1) func test_position_snap_apply_as_snap_false_is_ignored() -> void: var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, _select_response_json(true, ALPHA_ID, "ok", 1) ) var c := _make_client(transport) c.request_select_target_id(ALPHA_ID) var count_before: int = transport.request_count c.on_authoritative_position_snap(Vector3.ZERO, false) assert_that(transport.request_count).is_equal(count_before)