Files
settled-reach/client/tests/test_e2e_connection.gd
T
jpmschweitzerandClaude Fable 5 c64231e8ee fix(client): clear the test debt — 2 production bugs, suite fully green (T-973 et al.)
Production fixes surfaced by honest test triage:
- hud_groups.gd: _set_group_z crashed on freed HUD nodes — the typed loop
  variable errors before the is_instance_valid guard runs; prune first
- fog_state.gd: _resize cleared _prev_visible (world-space keys survive
  resizes), so pre-resize tiles never decayed VISIBLE→EXPLORED (D-059)

Test debt (T-928/929/934/935/936/937/938/939, T-864, T-973): lambda
local-capture bugs rewritten with array captures (now assert exact
emission counts), e2e suites updated to the current handshake +
StartupMessage protocol and stream-aware reads against the live binary,
fog perf test measures steady state, chime test pins the shipped 800ms
catalog asset (D-067 amended separately), monologue gdUnit4 API typo,
battery-warning tests follow the MetaScreen on_open lifecycle. 3 sprint2
proof tests revived (corner_reveal had passed from the wrong tile — NPC3
blocks (18,14); route corrected). Soft-skips converted to real do_skip
reporting. T-1068: 7 orphan .gd.uid deleted, _format_pop/_format_radius
deduped into atlas_format.gd (preload, no class_name — headless cache).

Suite: 1264 cases/20 failures → 1268/0, independently re-verified
(2536/2536, exit 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:01 +02:00

161 lines
5.4 KiB
GDScript

## 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 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")
## Pick a random high port to avoid conflicts in parallel CI runs.
## Range 49152-65535 is the dynamic/ephemeral port range (IANA).
static func _random_test_port() -> int:
return 49152 + (randi() % (65535 - 49152 + 1))
## Spawn server with port rotation — if the port is in use, the server exits
## immediately (bind failure). Detect this and retry with a new random port.
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
# Give server time to bind or fail
await get_tree().create_timer(0.15).timeout
if OS.is_process_running(_server_pid):
return true
# Server exited — port likely in use, try another
_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
## Protocol handshake (#555, #175): the server sends a HandshakeMessage as the
## first framed message; the client validates it and replies with a
## StartupMessage carrying world_seed before any input is accepted.
## Mirrors sim_bridge.gd's HANDSHAKING state.
func _do_handshake(world_seed: int = 42) -> bool:
var msg := PackedByteArray()
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
_bridge.poll()
msg = _bridge.poll_message()
if msg.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
if msg.is_empty():
push_warning("No HandshakeMessage received within %.1fs" % CONNECT_TIMEOUT)
return false
var MP = load("res://addons/messagepack/messagepack.gd")
var decoded: Variant = MP.decode(msg)
if decoded.status != null or not (decoded.value is Dictionary):
push_warning("Malformed HandshakeMessage")
return false
var startup_bytes := Protocol.encode_startup_message(world_seed)
if startup_bytes.is_empty():
return false
return _bridge.send_message(startup_bytes) == OK
# -- E2E: full round-trip through server binary --------------------------------
func test_send_input_receive_snapshot(
_do_skip := not FileAccess.file_exists(_server_binary_path()),
_skip_reason := "server binary not built — run `cargo build` in server/"
) -> void:
var server_path := _server_binary_path()
# Spawn server with port rotation (retries if port is in use)
var spawned := await _spawn_server(server_path)
assert_bool(spawned).is_true()
# Connect with retries (server needs time to accept)
_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:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
assert_bool(connected).is_true()
# Handshake + startup exchange (#555, #175) — required before input is accepted
var handshake_ok := await _do_handshake()
assert_bool(handshake_ok).override_failure_message(
"protocol handshake + StartupMessage exchange must complete (#555, #175)"
).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_greater(0)
# Find the player entity by kind (entity order is not guaranteed)
var player: Dictionary = {}
for entity in snapshot.entities:
if entity.kind.variant == "Player":
player = entity
break
assert_that(player.size()).is_greater(0)
# Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
# Render coords: tile center offset -> (16.5, 15.5, 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)