feat(client): add E2E connection test and batch encoding tests
D-030 Layer 3: E2E test spawns the Rust server binary, connects via LocalBridge, sends a batched MoveNorth input, and verifies the player moved to (16.5, 15.5). Also adds batch encoding roundtrip tests, framed batch test, and Player entity fixture decode test. 43/43 pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
## D-030 Layer 3: End-to-end connection test
|
||||
## Spawns the Rust simulation server, connects via LocalBridge,
|
||||
## sends a MoveNorth input, and verifies the snapshot response.
|
||||
## Requires: server binary built (cargo build in server/)
|
||||
class_name TestE2EConnection
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const TEST_PORT: int = 19876
|
||||
const CONNECT_TIMEOUT: float = 3.0
|
||||
const RESPONSE_TIMEOUT: float = 5.0
|
||||
|
||||
var _server_pid: int = -1
|
||||
var _bridge: LocalBridge = null
|
||||
|
||||
|
||||
func _server_binary_path() -> String:
|
||||
# Resolve path relative to the Godot project root
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
# Clean up regardless of test outcome
|
||||
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
|
||||
|
||||
|
||||
# -- E2E: full round-trip through server binary --------------------------------
|
||||
|
||||
func test_send_input_receive_snapshot() -> void:
|
||||
var server_path := _server_binary_path()
|
||||
if not FileAccess.file_exists(server_path):
|
||||
push_warning("E2E test skipped: server binary not found at %s" % server_path)
|
||||
return
|
||||
|
||||
# Spawn server
|
||||
var addr := "127.0.0.1:%d" % TEST_PORT
|
||||
_server_pid = OS.create_process(server_path, [addr])
|
||||
assert_that(_server_pid).is_greater(0)
|
||||
|
||||
# Connect with retries (server needs time to bind port)
|
||||
_bridge = LocalBridge.new()
|
||||
var connected := false
|
||||
var elapsed := 0.0
|
||||
while elapsed < CONNECT_TIMEOUT:
|
||||
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:
|
||||
connected = true
|
||||
break
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
|
||||
# Reset and retry
|
||||
_bridge.disconnect_from_server()
|
||||
_bridge.reset()
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
elapsed += 0.1
|
||||
|
||||
assert_bool(connected).is_true()
|
||||
|
||||
# Send batch input: MoveNorth at tick 0 (matching game_loop.rs test)
|
||||
var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}]
|
||||
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)
|
||||
|
||||
# Poll for snapshot response
|
||||
var snapshot_bytes := PackedByteArray()
|
||||
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)
|
||||
|
||||
# Decode snapshot
|
||||
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Server starts at tick 0, snapshot reflects state after processing
|
||||
assert_that(snapshot.tick).is_equal(0)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
# Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
|
||||
# Render coords: tile center offset → (16.5, 15.5, 0)
|
||||
var player: Dictionary = snapshot.entities[0]
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(15.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
@@ -123,6 +123,28 @@ func test_framed_protocol_input_roundtrip() -> void:
|
||||
assert_that(input.action.variant).is_equal("MoveNorth")
|
||||
|
||||
|
||||
func test_framed_protocol_batch_input_roundtrip() -> void:
|
||||
# Encode a batch of inputs (Vec<PlayerInput>), frame it, decode frame, verify wire format
|
||||
var inputs: Array = [
|
||||
{"tick": 0, "action_name": "MoveNorth"},
|
||||
{"tick": 0, "action_name": "Interact"},
|
||||
]
|
||||
var encoded := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
|
||||
var framed := LocalBridge.frame_encode(encoded)
|
||||
var decoded_frame: Variant = LocalBridge.frame_decode(framed)
|
||||
assert_that(decoded_frame).is_not_null()
|
||||
|
||||
# Verify the payload is a valid msgpack array matching server expectations
|
||||
var raw: Variant = Messagepack.decode(decoded_frame.payload)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(2)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
||||
|
||||
|
||||
# -- Diagonal movement wire mapping --------------------------------------------
|
||||
|
||||
func test_action_enum_to_wire_all_directions_clockwise() -> void:
|
||||
|
||||
@@ -42,33 +42,58 @@ func test_decode_snapshot_empty() -> void:
|
||||
assert_that(snapshot.entities.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_decode_snapshot_player() -> void:
|
||||
var bytes = _load_fixture("snapshot_player")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(1)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
var player = snapshot.entities[0]
|
||||
assert_that(player.entity_id).is_equal(100)
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
assert_that(player.kind.data).is_null()
|
||||
|
||||
|
||||
func test_decode_snapshot_multi_entity() -> void:
|
||||
var bytes = _load_fixture("snapshot_multi_entity")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(999)
|
||||
assert_that(snapshot.entities.size()).is_equal(3)
|
||||
assert_that(snapshot.entities.size()).is_equal(4)
|
||||
|
||||
# Player at (16.5, 16.5, 0)
|
||||
var player = snapshot.entities[0]
|
||||
assert_that(player.entity_id).is_equal(1)
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
|
||||
# NPC at (5, 10, 0)
|
||||
var npc = snapshot.entities[0]
|
||||
assert_that(npc.entity_id).is_equal(1)
|
||||
var npc = snapshot.entities[1]
|
||||
assert_that(npc.entity_id).is_equal(2)
|
||||
assert_float(npc.x).is_equal_approx(5.0, 0.001)
|
||||
assert_float(npc.y).is_equal_approx(10.0, 0.001)
|
||||
assert_that(npc.z).is_equal(0)
|
||||
assert_that(npc.kind.variant).is_equal("Npc")
|
||||
|
||||
# Object at (15.5, 3, 1)
|
||||
var obj = snapshot.entities[1]
|
||||
assert_that(obj.entity_id).is_equal(2)
|
||||
var obj = snapshot.entities[2]
|
||||
assert_that(obj.entity_id).is_equal(3)
|
||||
assert_float(obj.x).is_equal_approx(15.5, 0.001)
|
||||
assert_float(obj.y).is_equal_approx(3.0, 0.001)
|
||||
assert_that(obj.z).is_equal(1)
|
||||
assert_that(obj.kind.variant).is_equal("Object")
|
||||
|
||||
# Terrain at (0, 0, -1)
|
||||
var terrain = snapshot.entities[2]
|
||||
assert_that(terrain.entity_id).is_equal(3)
|
||||
var terrain = snapshot.entities[3]
|
||||
assert_that(terrain.entity_id).is_equal(4)
|
||||
assert_float(terrain.x).is_equal_approx(0.0, 0.001)
|
||||
assert_float(terrain.y).is_equal_approx(0.0, 0.001)
|
||||
assert_that(terrain.z).is_equal(-1)
|
||||
@@ -169,6 +194,63 @@ func test_encode_produces_nonempty_bytes() -> void:
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
|
||||
# -- Batch input encoding (Vec<PlayerInput> wire format) -----------------------
|
||||
|
||||
func test_encode_player_inputs_single() -> void:
|
||||
var inputs: Array = [{"tick": 10, "action_name": "MoveNorth"}]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
# Decode as raw msgpack — should be an array with one element
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(1)
|
||||
assert_that(raw.value[0]["tick"]).is_equal(10)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
|
||||
|
||||
func test_encode_player_inputs_multiple() -> void:
|
||||
var inputs: Array = [
|
||||
{"tick": 1, "action_name": "MoveNorth"},
|
||||
{"tick": 1, "action_name": "Interact"},
|
||||
{"tick": 2, "action_name": "MoveSouthwest"},
|
||||
]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value.size()).is_equal(3)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
||||
assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest")
|
||||
|
||||
|
||||
func test_encode_player_inputs_with_data_variant() -> void:
|
||||
var inputs: Array = [
|
||||
{"tick": 5, "action_name": "UsePerceptionMode", "action_data": "thermal"},
|
||||
]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value[0]["action"] is Dictionary).is_true()
|
||||
assert_that(raw.value[0]["action"]["UsePerceptionMode"]).is_equal("thermal")
|
||||
|
||||
|
||||
func test_encode_player_inputs_empty() -> void:
|
||||
var inputs: Array = []
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
|
||||
|
||||
func test_decode_diagonal_fixtures() -> void:
|
||||
|
||||
Reference in New Issue
Block a user