Files
CosmicClash/Game/tests/main_menu_test_hooks.gd
T
Josh Creek c02aad66a0 fix(ui): keep menu content reachable at any window size
The main menu clipped its own title and bottom button in debug builds. The
project lays out in a hard-fixed 1920x1080 logical viewport
(window/stretch/mode="viewport"), and the only overflow strategy in the
scene was a CenterContainer, which centres its child rather than clipping
and scrolling. With DevSection visible the content measures 1133px against
1080, so roughly 53px spilled off both ends with no way to reach it — and
main_menu.gd grabs focus on a button that may itself be off-screen.

Worth recording because it is counter-intuitive: this is not
resolution-dependent. Because the viewport is fixed, a 4K display magnifies
the same clipped 1080p frame rather than giving the menu more room, so the
fix has to make the layout scroll, not scale.

Each menu is now MarginContainer > ScrollContainer > CenterContainer >
VBoxContainer. ScrollContainer sizes its child to max(own size, child
minimum), so an expanding CenterContainer keeps today's centred look when
the content is short and grows past the viewport when it is tall — which is
exactly when scrolling should start. follow_focus is on so keyboard and
controller navigation cannot strand focus off-screen. Lobby and matchmaking
share the same shape and get the same treatment before they hit the same
wall; settings gained its wrapper alongside the Controls tab.

Also stops the dev bot dropdowns widening the whole menu: they are filled
from res://bots filenames and expand horizontally, so a long checkpoint
name dragged the layout past its 420px minimum.

test_menu_layout asserts each screen's bottom-most control really sits
inside a ScrollContainer. That is a structural guard against the wrapper
being removed or a new section being added outside it — not proof that
nothing visually clips, which was checked by hand at 1000x600, 1280x720 and
1920x1080.
2026-09-06 20:41:41 +01:00

115 lines
5.2 KiB
GDScript

extends Node
# Test-only helper (tests/main_menu_smoke.gd). Not referenced by production
# code. Registered as a temporary project autoload only while running this
# test — see the test's own header for why an autoload (rather than a
# scene-child driver) is needed: main_menu.tscn IS the real current_scene
# here (run directly via --path Game res://scenes/main_menu.tscn, exactly
# like production), so unlike lobby_smoke.gd's driver this doesn't even
# need to survive a scene swap — it just needs to exist independently of
# main_menu.gd so main_menu.gd itself stays untouched by test concerns.
var _role := ""
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--role="):
_role = arg.substr("--role=".length())
if _role.is_empty():
return
# main_menu.gd's own _ready() (which builds %-unique-name refs) must run
# before we touch its nodes; autoloads run first, so wait a frame.
await get_tree().process_frame
await get_tree().process_frame
match _role:
"host":
_run_host()
"join_ok":
_run_join_ok()
"join_refused":
_run_join_refused()
"join_cancel":
_run_join_cancel()
func _run_host() -> void:
var menu := get_tree().current_scene
var host_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/HostButton")
host_btn.emit_signal("pressed")
await get_tree().create_timer(1.0).timeout
var scene := get_tree().current_scene
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
print("SMOKE %s: host -> current_scene=%s" % ["PASS" if ok else "FAIL", scene.scene_file_path if scene else "null"])
if not ok:
_finish(false, "host transition check failed")
return
# Stay up long enough for a separate join_ok/join_refused/join_cancel
# process (started after this one) to actually exercise the host —
# unlike _finish()'s normal 0.3s beat, this test's whole point is being
# a live target for a while.
await get_tree().create_timer(6.0).timeout
_finish(true, "host ran and stayed up for a joiner")
func _run_join_ok() -> void:
# Give the host process (started first by the shell script) time to be listening.
await get_tree().create_timer(1.5).timeout
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press = %s" % str(overlay.visible))
await get_tree().create_timer(2.0).timeout
var scene := get_tree().current_scene
var ok := scene != null and scene.scene_file_path == "res://scenes/lobby.tscn"
_finish(ok, "join_ok -> current_scene=%s" % (scene.scene_file_path if scene else "null"))
func _run_join_refused() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "127.0.0.1"
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
print("SMOKE INFO: overlay visible right after Join press (no server) = %s" % str(overlay.visible))
# main_menu.gd's own CONNECT_TIMEOUT_SECONDS (6.0) is what actually
# bounds this now — ENet's own connection_failed proved unbounded in
# practice against a genuinely refused loopback connection.
await get_tree().create_timer(8.0).timeout
var error_label: Label = menu.get_node("%MultiplayerErrorLabel")
var still_on_menu := get_tree().current_scene == menu
var ok := still_on_menu and not overlay.visible and error_label.visible
_finish(ok, "join_refused -> still_on_menu=%s overlay_visible=%s error_visible=%s error_text='%s'" % [
str(still_on_menu), str(overlay.visible), str(error_label.visible), error_label.text
])
func _run_join_cancel() -> void:
var menu := get_tree().current_scene
var address_edit: LineEdit = menu.get_node("%JoinAddressEdit")
address_edit.text = "10.255.255.1" # non-routable; connect attempt just hangs until timeout/cancel
var join_btn: Button = menu.get_node("MarginContainer/ScrollContainer/CenterContainer/VBoxContainer/JoinRow/JoinButton")
join_btn.emit_signal("pressed")
var overlay: Control = menu.get_node("%ConnectingOverlay")
await get_tree().create_timer(0.5).timeout
var overlay_shown := overlay.visible
var cancel_btn: Button = menu.get_node("%ConnectingCancelButton")
cancel_btn.emit_signal("pressed")
await get_tree().create_timer(0.5).timeout
var still_on_menu := get_tree().current_scene == menu
var ok := overlay_shown and not overlay.visible and still_on_menu and not NetworkManager.is_client
_finish(ok, "join_cancel -> overlay_shown=%s overlay_now=%s still_on_menu=%s is_client=%s" % [
str(overlay_shown), str(overlay.visible), str(still_on_menu), str(NetworkManager.is_client)
])
func _finish(success: bool, message: String) -> void:
print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)