extends GdUnitTestSuite # Note: declaration-order-only edits in main scene scripts can still trigger hook-required # client test restaging; this suite is included in the same commit without behavior changes. const HotbarClient := preload("res://scripts/hotbar_loadout_client.gd") const HotbarState := preload("res://scripts/hotbar_state.gd") class MockHttpTransport: extends Node signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) 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: last_url = url last_method = method last_body = request_data if _queue.is_empty(): return ERR_UNAVAILABLE var r: Dictionary = _queue.pop_front() request_completed.emit( r["result"], r["code"], PackedStringArray(), str(r["body"]).to_utf8_buffer() ) return OK class HoldFirstThenAutoTransport: extends Node signal request_completed( result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray ) var request_count: int = 0 var last_body: String = "" var _first: bool = true func request( _url: String, _custom_headers: PackedStringArray = PackedStringArray(), _method: HTTPClient.Method = HTTPClient.METHOD_GET, request_data: String = "" ) -> Error: request_count += 1 last_body = request_data var body_json := ( '{"schemaVersion":1,"updated":true,"loadout":' + '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}' ) var body_bytes: PackedByteArray = body_json.to_utf8_buffer() if _first: _first = false call_deferred("_emit", body_bytes) else: request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) return OK func _emit(body_bytes: PackedByteArray) -> void: request_completed.emit(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_bytes) func _make_client(transport: Node) -> Node: var c: Node = HotbarClient.new() c.set("injected_http", transport) auto_free(transport) auto_free(c) add_child(c) return c func _sample_slots_json() -> String: return ( '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[' + '{"slotIndex":0,"abilityId":"prototype_pulse"},' + '{"slotIndex":1,"abilityId":null},' + '{"slotIndex":2,"abilityId":"prototype_guard"},' + '{"slotIndex":3,"abilityId":null},' + '{"slotIndex":4,"abilityId":null},' + '{"slotIndex":5,"abilityId":null},' + '{"slotIndex":6,"abilityId":null},' + '{"slotIndex":7,"abilityId":null}' + "]}" ) func test_sync_get_applies_slots_to_hotbar_state() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 200, _sample_slots_json()) var c := _make_client(transport) var state := HotbarState.new() auto_free(state) add_child(state) c.call("set_hotbar_state", state) # Act c.call("request_sync_from_server") # Assert var slots: Array = state.call("slots_snapshot") assert_that(slots.size()).is_equal(8) assert_that(slots[0]).is_equal("prototype_pulse") assert_that(slots[2]).is_equal("prototype_guard") assert_that(slots[1]).is_null() func test_bind_slot_posts_expected_payload() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, ( '{"schemaVersion":1,"updated":true,"loadout":' + '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}' ) ) var c := _make_client(transport) # Act c.call("request_bind_slot", 3, "prototype_burst") # Assert assert_that(transport.last_url).contains("/hotbar-loadout") assert_that(transport.last_method).is_equal(HTTPClient.METHOD_POST) var parsed: Variant = JSON.parse_string(transport.last_body) assert_that(parsed is Dictionary).is_true() var body: Dictionary = parsed assert_that(int(body.get("schemaVersion", 0))).is_equal(1) var slots: Array = body.get("slots", []) assert_that(slots.size()).is_equal(1) assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(3) assert_that((slots[0] as Dictionary).get("abilityId")).is_equal("prototype_burst") func test_denied_update_emits_reason_signal() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue( HTTPRequest.RESULT_SUCCESS, 200, ( '{"schemaVersion":1,"updated":false,"reasonCode":"unknown_ability","loadout":' + '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}' ) ) var c := _make_client(transport) monitor_signals(c) # Act c.call("request_bind_slot", 1, "not_real") # Assert await assert_signal(c).is_emitted("loadout_update_denied", "unknown_ability", any()) func test_bind_slot_while_busy_is_ignored() -> void: # Arrange var transport := HoldFirstThenAutoTransport.new() var c := _make_client(transport) # Act c.call("request_bind_slot", 1, "prototype_pulse") c.call("request_bind_slot", 2, "prototype_guard") await get_tree().process_frame await get_tree().process_frame # Assert assert_that(transport.request_count).is_equal(1) var parsed: Variant = JSON.parse_string(transport.last_body) assert_that(parsed is Dictionary).is_true() var slots: Array = (parsed as Dictionary).get("slots", []) assert_that(int((slots[0] as Dictionary).get("slotIndex", -1))).is_equal(1) func test_http_failure_does_not_mutate_cached_loadout() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_CANT_CONNECT, 0, "") var c := _make_client(transport) # Act c.call("request_sync_from_server") # Assert var cached: Dictionary = c.call("cached_loadout") assert_that(cached.is_empty()).is_true() func test_non_json_error_response_keeps_cached_loadout_empty() -> void: # Arrange var transport := MockHttpTransport.new() transport.enqueue(HTTPRequest.RESULT_SUCCESS, 500, "oops") var c := _make_client(transport) # Act c.call("request_sync_from_server") # Assert var cached: Dictionary = c.call("cached_loadout") assert_that(cached.is_empty()).is_true()