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>
This commit is contained in:
@@ -48,38 +48,104 @@ func after_test() -> void:
|
||||
_server_pid = -1
|
||||
|
||||
|
||||
## Spawn the server, connect, and complete the protocol handshake.
|
||||
## Binary absence is handled by per-test `_do_skip` — by the time this runs the
|
||||
## binary exists, so any failure here is a real failure (asserted loudly).
|
||||
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)
|
||||
assert_bool(spawned).override_failure_message(
|
||||
"server spawn failed after %d port attempts" % MAX_PORT_ATTEMPTS
|
||||
).is_true()
|
||||
if not spawned:
|
||||
return false
|
||||
|
||||
_bridge = LocalBridge.new()
|
||||
var connected := false
|
||||
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
|
||||
break
|
||||
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
|
||||
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
|
||||
|
||||
return false
|
||||
assert_bool(connected).override_failure_message(
|
||||
"TCP connect to spawned server failed within %.1fs" % CONNECT_TIMEOUT
|
||||
).is_true()
|
||||
if not connected:
|
||||
return false
|
||||
|
||||
# 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()
|
||||
return handshake_ok
|
||||
|
||||
|
||||
## Send a batch input and receive the snapshot response.
|
||||
func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant = null) -> Variant:
|
||||
## 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
|
||||
|
||||
|
||||
## Send a batch input and receive a snapshot that reflects it.
|
||||
## The server free-runs at ~20 ticks/sec and STREAMS a snapshot every tick —
|
||||
## it is not request/response. Drain stale queued snapshots first, then:
|
||||
## - expect_position != null (Vector2, render coords): wait until the player
|
||||
## reaches that position and assert it. Deterministic under load — tick
|
||||
## margins race the free-running server when the test process is descheduled
|
||||
## between drain and send.
|
||||
## - expect_position == null: wait for a snapshot at least 2 ticks past the
|
||||
## drain point (enough for no-op actions like Interact).
|
||||
func _send_and_receive(
|
||||
action_name: String, tick: int = 0, action_data: Variant = null,
|
||||
expect_position: Variant = null
|
||||
) -> Variant:
|
||||
# Drain queued stale snapshots, remembering the newest tick seen.
|
||||
var last_tick: int = -1
|
||||
_bridge.poll()
|
||||
var pending := _bridge.poll_message()
|
||||
while pending.size() > 0:
|
||||
var stale: Variant = Protocol.decode_snapshot(pending)
|
||||
if stale != null:
|
||||
last_tick = stale.tick
|
||||
_bridge.poll()
|
||||
pending = _bridge.poll_message()
|
||||
|
||||
var input_entry := {"tick": tick, "action_name": action_name}
|
||||
if action_data != null:
|
||||
input_entry["action_data"] = action_data
|
||||
@@ -89,19 +155,42 @@ func _send_and_receive(action_name: String, tick: int = 0, action_data: Variant
|
||||
var send_err := _bridge.send_message(encoded)
|
||||
assert_that(send_err).is_equal(OK)
|
||||
|
||||
var snapshot_bytes := PackedByteArray()
|
||||
# Wait for a snapshot that reflects the processed input.
|
||||
var min_tick: int = last_tick + 2
|
||||
var snapshot: Variant = null
|
||||
var elapsed := 0.0
|
||||
while elapsed < RESPONSE_TIMEOUT:
|
||||
_bridge.poll()
|
||||
snapshot_bytes = _bridge.poll_message()
|
||||
if snapshot_bytes.size() > 0:
|
||||
break
|
||||
var msg := _bridge.poll_message()
|
||||
if msg.size() > 0:
|
||||
var decoded: Variant = Protocol.decode_snapshot(msg)
|
||||
if decoded == null:
|
||||
continue # undecodable frame — keep draining
|
||||
if expect_position != null:
|
||||
snapshot = decoded # keep latest so a timeout reports actual state
|
||||
var player := _find_player(decoded)
|
||||
if (
|
||||
not player.is_empty()
|
||||
and is_equal_approx(player.x, expect_position.x)
|
||||
and is_equal_approx(player.y, expect_position.y)
|
||||
):
|
||||
break
|
||||
elif decoded.tick >= min_tick:
|
||||
snapshot = decoded
|
||||
break
|
||||
continue
|
||||
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()
|
||||
assert_that(snapshot).override_failure_message(
|
||||
"no snapshot received within %.1fs after '%s'" % [RESPONSE_TIMEOUT, action_name]
|
||||
).is_not_null()
|
||||
if expect_position != null and snapshot != null:
|
||||
var player := _find_player(snapshot)
|
||||
var actual := Vector2(player.x, player.y) if not player.is_empty() else Vector2.INF
|
||||
assert_that(actual.is_equal_approx(expect_position)).override_failure_message(
|
||||
"player must reach %s after '%s' — last seen %s" % [expect_position, action_name, actual]
|
||||
).is_true()
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -115,7 +204,10 @@ static func _find_player(snapshot: Dictionary) -> Dictionary:
|
||||
|
||||
# -- Movement roundtrip: send movement, verify position changes ----------------
|
||||
|
||||
func test_movement_roundtrip() -> void:
|
||||
func test_movement_roundtrip(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
@@ -132,14 +224,14 @@ func test_movement_roundtrip() -> void:
|
||||
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 snap1: Dictionary = await _send_and_receive("MoveNorth", 1, null, Vector2(16.5, 15.5))
|
||||
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 snap2: Dictionary = await _send_and_receive("MoveEast", 2, null, Vector2(17.5, 15.5))
|
||||
var p2 := _find_player(snap2)
|
||||
assert_that(p2.size()).is_greater(0)
|
||||
assert_float(p2.x).is_equal_approx(17.5, 0.001)
|
||||
@@ -151,7 +243,10 @@ func test_movement_roundtrip() -> void:
|
||||
|
||||
# -- Interact roundtrip: server accepts without crashing -----------------------
|
||||
|
||||
func test_interact_roundtrip() -> void:
|
||||
func test_interact_roundtrip(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
@@ -175,13 +270,16 @@ func test_interact_roundtrip() -> void:
|
||||
|
||||
# -- Mixed sequence: movement then interact in one session ---------------------
|
||||
|
||||
func test_move_then_interact() -> void:
|
||||
func test_move_then_interact(
|
||||
_do_skip := not FileAccess.file_exists(_server_binary_path()),
|
||||
_skip_reason := "server binary not built — run `cargo build` in server/"
|
||||
) -> void:
|
||||
var ok := await _connect_to_server()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
# Move player first
|
||||
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0)
|
||||
# Move player first — (16,16) → (16,15) → render (16.5, 15.5)
|
||||
var snap1: Dictionary = await _send_and_receive("MoveNorth", 0, null, Vector2(16.5, 15.5))
|
||||
var p1 := _find_player(snap1)
|
||||
assert_that(p1.size()).is_greater(0)
|
||||
var moved_x: float = p1.x
|
||||
|
||||
Reference in New Issue
Block a user