From 33f4f6078ca00650aba1722f1456c5ee6178518c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 13 Feb 2026 01:48:35 +0100 Subject: [PATCH] feat(client): add input roundtrip integration test (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/tests/test_input_roundtrip.gd | 198 +++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 client/tests/test_input_roundtrip.gd diff --git a/client/tests/test_input_roundtrip.gd b/client/tests/test_input_roundtrip.gd new file mode 100644 index 000000000..3a99fec44 --- /dev/null +++ b/client/tests/test_input_roundtrip.gd @@ -0,0 +1,198 @@ +## #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)