extends Node # Test-only helper (tests/lobby_smoke.gd). Not a project autoload — # production code never references this. Exists because lobby.tscn is # loaded via change_scene_to_file() in the real flow (matching # main_menu.gd's future Host/Join UI), which frees whatever node initiated # the load — a test driver can't keep orchestrating from a node that just # got freed. The driver instead add_child()s this directly under # get_tree().root (a sibling of current_scene, not a descendant of it), so # it survives the scene swap and can drive the check from outside lobby.gd, # which stays untouched by test concerns. signal finished(success: bool, message: String) const SETTLE_SECONDS := 3.0 const AFTER_PRESS_SECONDS := 1.0 # The host process starts ~1.5s before the client (see the shell # invocation in both roles' header comments) and the client doesn't finish # its own SETTLE_SECONDS + AFTER_PRESS_SECONDS flow (plus its own 0.3s # _finish delay) until roughly 1.5 + 3.0 + 1.0 + 0.3 ≈ 5.8s into the # host's own timeline. Verifying and quitting the instant the host's own # checks pass (~2-2.5s in) would drop the connection out from under the # client mid-flow. Print the result as soon as it's known, but hold the # actual quit() open past the client's expected finish time. const MIN_HOST_LIFETIME_SECONDS := 7.0 # Adversarial-review regression: the host role in lobby_smoke.gd used to # never load lobby.tscn at all, so lobby.gd's is_server branch (the # read-only view main_menu.gd's own Host button routes a self-hosting # player into) had never actually run under this task's own test suite — # only main_menu_test_hooks.gd's separate, non-permanent task-1.7 test had # exercised it. This closes that gap for good. func run_host_test() -> void: var start_ms := Time.get_ticks_msec() var deadline := start_ms + int((SETTLE_SECONDS + 5.0) * 1000.0) while MatchNet.roster.is_empty() and Time.get_ticks_msec() < deadline: await get_tree().process_frame var lobby := get_tree().current_scene if lobby == null or not lobby.has_method("_refresh"): await _finish_and_quit(false, "current_scene is not the lobby scene", start_ms) return if MatchNet.roster.is_empty(): await _finish_and_quit(false, "no client joined before timeout", start_ms) return # Give _refresh() a beat to process the player_joined signal it just got. await get_tree().create_timer(0.3).timeout var controls_row: Control = lobby.get_node("%ControlsRow") var team0: VBoxContainer = lobby.get_node("%Team0List") var team1: VBoxContainer = lobby.get_node("%Team1List") var status: Label = lobby.get_node("%StatusLabel") var total_rows := team0.get_child_count() + team1.get_child_count() # The server is never a roster member (§1.1 decision 2) — its own # lobby.gd instance must show a read-only view, no team/ready controls. var controls_hidden := not controls_row.visible var rows_match_roster := total_rows == MatchNet.roster.size() var status_ok := status.text.begins_with("Hosting") var success := controls_hidden and rows_match_roster and status_ok await _finish_and_quit(success, "controls_hidden=%s rows=%d roster=%d status='%s'" % [ str(controls_hidden), total_rows, MatchNet.roster.size(), status.text ], start_ms) func _finish_and_quit(success: bool, message: String, start_ms: int) -> void: finished.emit(success, message) print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message]) var elapsed_sec := float(Time.get_ticks_msec() - start_ms) / 1000.0 var remaining := MIN_HOST_LIFETIME_SECONDS - elapsed_sec if remaining > 0.0: await get_tree().create_timer(remaining).timeout NetworkManager.shutdown() get_tree().quit(0 if success else 1) func run_client_test() -> void: await get_tree().create_timer(SETTLE_SECONDS).timeout var lobby := get_tree().current_scene if lobby == null or not lobby.has_method("_refresh"): finished.emit(false, "current_scene is not the lobby scene") return var switch_btn: Button = lobby.get_node("%SwitchTeamButton") var ready_btn: CheckButton = lobby.get_node("%ReadyButton") switch_btn.emit_signal("pressed") ready_btn.button_pressed = true ready_btn.emit_signal("toggled", true) await get_tree().create_timer(AFTER_PRESS_SECONDS).timeout var team0: VBoxContainer = lobby.get_node("%Team0List") var team1: VBoxContainer = lobby.get_node("%Team1List") var status: Label = lobby.get_node("%StatusLabel") var total_rows := team0.get_child_count() + team1.get_child_count() var my_id := multiplayer.get_unique_id() var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id) var info_str := "roster_size=%d team0_rows=%d team1_rows=%d status='%s'" % [ MatchNet.roster.size(), team0.get_child_count(), team1.get_child_count(), status.text ] var team_ready_ok := false if info != null: info_str += " my_team=%d my_ready=%s" % [info.team, str(info.ready)] # Started on whatever team balancing picked (0, since first # joiner), pressed Switch Team once -> should now be on team 1, # and pressed Ready -> should be true. team_ready_ok = info.team == 1 and info.ready == true print("SMOKE INFO: " + info_str) var rows_match_roster := total_rows == MatchNet.roster.size() var success := rows_match_roster and team_ready_ok var message := "rows=%d roster=%d team_ready_ok=%s" % [total_rows, MatchNet.roster.size(), str(team_ready_ok)] finished.emit(success, message) # The driver that called run_client_test() is gone by now — it was # get_tree().current_scene before the change_scene_to_file() that # loaded the lobby, so it got freed in the swap, taking its signal # connection to `finished` down with it (Godot auto-disconnects when # either end of a connection is freed). This autoload outlives that # swap, so it's what actually ends the process. print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message]) get_tree().quit(0 if success else 1)