Three tests validating the full client-server input path through a live server: movement roundtrip (MoveNorth/MoveEast with position verification), interact roundtrip (unit variant accepted as no-op), and mixed sequence (movement then interact preserves position). Catches integration seams that unit tests miss — each test spawns a real server binary on a random port, connects via LocalBridge, and exercises the TCP→deserialize→simulation→snapshot→decode pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
6.5 KiB
GDScript
199 lines
6.5 KiB
GDScript
## #411: Integration test — full input roundtrip through live server.
|
|
## Validates the complete path: encode inputs → TCP wire → server deserialize →
|
|
## simulation systems → ObserverSnapshot → client decode.
|
|
## Catches integration seams that unit tests miss by exercising both movement
|
|
## and interaction actions against the real server binary.
|
|
## Requires: server binary built (cargo build in server/)
|
|
class_name TestInputRoundtrip
|
|
extends GdUnitTestSuite
|
|
|
|
const CONNECT_TIMEOUT: float = 3.0
|
|
const RESPONSE_TIMEOUT: float = 5.0
|
|
const MAX_PORT_ATTEMPTS: int = 5
|
|
|
|
var _server_pid: int = -1
|
|
var _bridge: LocalBridge = null
|
|
var _test_port: int = 0
|
|
|
|
|
|
func _server_binary_path() -> String:
|
|
var project_dir := ProjectSettings.globalize_path("res://")
|
|
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
|
|
|
|
|
static func _random_test_port() -> int:
|
|
return 49152 + (randi() % (65535 - 49152 + 1))
|
|
|
|
|
|
func _spawn_server(server_path: String) -> bool:
|
|
for attempt in range(MAX_PORT_ATTEMPTS):
|
|
_test_port = _random_test_port()
|
|
var addr := "127.0.0.1:%d" % _test_port
|
|
_server_pid = OS.create_process(server_path, [addr])
|
|
if _server_pid <= 0:
|
|
continue
|
|
await get_tree().create_timer(0.15).timeout
|
|
if OS.is_process_running(_server_pid):
|
|
return true
|
|
_server_pid = -1
|
|
return false
|
|
|
|
|
|
func after_test() -> void:
|
|
if _bridge != null:
|
|
_bridge.disconnect_from_server()
|
|
_bridge = null
|
|
if _server_pid > 0 and OS.is_process_running(_server_pid):
|
|
OS.kill(_server_pid)
|
|
_server_pid = -1
|
|
|
|
|
|
func _connect_to_server() -> bool:
|
|
var server_path := _server_binary_path()
|
|
if not FileAccess.file_exists(server_path):
|
|
push_warning("Input roundtrip test skipped: server binary not found at %s" % server_path)
|
|
return false
|
|
|
|
var spawned := await _spawn_server(server_path)
|
|
if not spawned:
|
|
return false
|
|
|
|
_bridge = LocalBridge.new()
|
|
var elapsed := 0.0
|
|
while elapsed < CONNECT_TIMEOUT:
|
|
if _server_pid > 0 and not OS.is_process_running(_server_pid):
|
|
push_warning("Server process died during connection")
|
|
return false
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
|
|
_bridge.connect_to_server("127.0.0.1", _test_port)
|
|
_bridge.poll()
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
return true
|
|
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
|
|
_bridge.disconnect_from_server()
|
|
_bridge.reset()
|
|
await get_tree().create_timer(0.1).timeout
|
|
elapsed += 0.1
|
|
|
|
return false
|
|
|
|
|
|
## Send a batch input and receive the snapshot response.
|
|
func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant = null) -> Variant:
|
|
var input_entry := {"tick": tick, "action_name": action_name}
|
|
if action_data != null:
|
|
input_entry["action_data"] = action_data
|
|
var inputs: Array = [input_entry]
|
|
var encoded := Protocol.encode_player_inputs(inputs)
|
|
assert_that(encoded.size()).is_greater(0)
|
|
var send_err := _bridge.send_message(encoded)
|
|
assert_that(send_err).is_equal(OK)
|
|
|
|
var snapshot_bytes := PackedByteArray()
|
|
var elapsed := 0.0
|
|
while elapsed < RESPONSE_TIMEOUT:
|
|
_bridge.poll()
|
|
snapshot_bytes = _bridge.poll_message()
|
|
if snapshot_bytes.size() > 0:
|
|
break
|
|
await get_tree().create_timer(0.05).timeout
|
|
elapsed += 0.05
|
|
|
|
assert_that(snapshot_bytes.size()).is_greater(0)
|
|
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
|
|
assert_that(snapshot).is_not_null()
|
|
return snapshot
|
|
|
|
|
|
## Find the player entity in a snapshot by kind.
|
|
static func _find_player(snapshot: Dictionary) -> Dictionary:
|
|
for entity in snapshot.entities:
|
|
if entity.kind.variant == "Player":
|
|
return entity
|
|
return {}
|
|
|
|
|
|
# -- Movement roundtrip: send movement, verify position changes ----------------
|
|
|
|
func test_movement_roundtrip() -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Use Interact (server no-op) to get a baseline snapshot without moving.
|
|
var baseline: Dictionary = await _send_and_receive("Interact", 0)
|
|
var player := _find_player(baseline)
|
|
assert_that(player.size()).is_greater(0)
|
|
|
|
# Player starts at (16,16) → render coords (16.5, 16.5)
|
|
var start_x: float = player.x
|
|
var start_y: float = player.y
|
|
assert_float(start_x).is_equal_approx(16.5, 0.001)
|
|
assert_float(start_y).is_equal_approx(16.5, 0.001)
|
|
|
|
# Send MoveNorth — player should move to (16, 15) → (16.5, 15.5)
|
|
var snap1: Dictionary = await _send_and_receive("MoveNorth", 1)
|
|
var p1 := _find_player(snap1)
|
|
assert_that(p1.size()).is_greater(0)
|
|
assert_float(p1.x).is_equal_approx(16.5, 0.001)
|
|
assert_float(p1.y).is_equal_approx(15.5, 0.001)
|
|
|
|
# Send MoveEast — player should move to (17, 15) → (17.5, 15.5)
|
|
var snap2: Dictionary = await _send_and_receive("MoveEast", 2)
|
|
var p2 := _find_player(snap2)
|
|
assert_that(p2.size()).is_greater(0)
|
|
assert_float(p2.x).is_equal_approx(17.5, 0.001)
|
|
assert_float(p2.y).is_equal_approx(15.5, 0.001)
|
|
|
|
# Verify position actually changed from baseline
|
|
assert_that(p2.x != start_x or p2.y != start_y).is_true()
|
|
|
|
|
|
# -- Interact roundtrip: server accepts without crashing -----------------------
|
|
|
|
func test_interact_roundtrip() -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Send Interact action — unit variant, no target data.
|
|
# Server accepts it (currently a no-op) and responds with a valid snapshot.
|
|
var snapshot: Dictionary = await _send_and_receive("Interact", 0)
|
|
|
|
# Snapshot should be valid with correct protocol version
|
|
assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION)
|
|
assert_that(snapshot.entities.size()).is_greater(0)
|
|
|
|
# Player should be at start position (Interact doesn't move)
|
|
var player := _find_player(snapshot)
|
|
assert_that(player.size()).is_greater(0)
|
|
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
|
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
|
|
|
# Send Interact again at next tick — server should still accept it
|
|
var snap2: Dictionary = await _send_and_receive("Interact", 1)
|
|
assert_that(snap2).is_not_null()
|
|
assert_that(snap2.version).is_equal(Protocol.PROTOCOL_VERSION)
|
|
|
|
|
|
# -- Mixed sequence: movement then interact in one session ---------------------
|
|
|
|
func test_move_then_interact() -> void:
|
|
var ok := await _connect_to_server()
|
|
if not ok:
|
|
return
|
|
|
|
# Move player first
|
|
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0)
|
|
var p1 := _find_player(snap1)
|
|
assert_that(p1.size()).is_greater(0)
|
|
var moved_x: float = p1.x
|
|
var moved_y: float = p1.y
|
|
|
|
# Now send Interact — player should stay where they moved to
|
|
var snap2: Dictionary = await _send_and_receive("Interact", 1)
|
|
var p2 := _find_player(snap2)
|
|
assert_that(p2.size()).is_greater(0)
|
|
assert_float(p2.x).is_equal_approx(moved_x, 0.001)
|
|
assert_float(p2.y).is_equal_approx(moved_y, 0.001)
|