extends "res://tests/test_case.gd" # Guards the menu screens against the overflow bug that made the main menu # unusable: the layout runs in a hard-fixed 1920x1080 logical viewport # (window/stretch/mode="viewport"), and a CenterContainer centres its child # rather than clipping it, so once the content's minimum height passed 1080 the # top and bottom spilled off-screen with no way to reach them. In a debug build # the main menu's DevSection pushed it to roughly 1120px, cutting off both the # title and the last button. # # What this file can and cannot do, stated plainly: it asserts the *structure* # that makes overflow reachable — the bottom-most control of each screen sits # inside a ScrollContainer, and that container follows focus so keyboard and # controller navigation cannot strand the player on an off-screen row. It does # not measure anything, so it cannot prove nothing visually clips; that check is # manual, at several window sizes. It exists to stop the wrapper being removed # or a new section being added outside it. # # Scenes are inspected through PackedScene.get_state() rather than instantiated. # main_menu.gd and lobby.gd connect NetworkManager signals and scan res://bots # in _ready(), so instantiating them in a unit test would be doing real work to # answer a question about the scene file. # scene path -> the control furthest down that screen, i.e. the first thing to # be lost to overflow. Naming a specific leaf rather than "some ScrollContainer # exists" is what makes this assertion say something: a wrapper that does not # actually contain the content would still pass the weaker version. const DEEPEST_CONTROLS := { "res://scenes/main_menu.tscn": "SpectateButton", "res://scenes/settings.tscn": "ResetButton", "res://scenes/lobby.tscn": "LeaveButton", "res://scenes/matchmaking.tscn": "BackButton", } # Returns node index -> "Parent/Path/Name" for every node in the scene state, # reconstructing full paths from SceneState's parent-relative storage. func _node_paths(state: SceneState) -> Dictionary: var paths := {} for i in state.get_node_count(): var parent := state.get_node_path(i, true) var name := String(state.get_node_name(i)) var parent_str := String(parent) if parent_str == "." or parent_str == "": paths[i] = name else: paths[i] = "%s/%s" % [parent_str, name] return paths func _property(state: SceneState, index: int, wanted: String, fallback): for p in state.get_node_property_count(index): if String(state.get_node_property_name(index, p)) == wanted: return state.get_node_property_value(index, p) return fallback func test_every_menu_scene_loads() -> void: for scene_path in DEEPEST_CONTROLS: var scene := load(scene_path) assert_true(scene is PackedScene, "%s loads as a PackedScene" % scene_path) func test_the_bottom_of_each_menu_sits_inside_a_scroll_container() -> void: for scene_path in DEEPEST_CONTROLS: var scene: PackedScene = load(scene_path) if scene == null: assert_true(false, "%s failed to load" % scene_path) continue var state := scene.get_state() var paths := _node_paths(state) # Collect the paths of every ScrollContainer in the scene... var scroll_paths := [] for i in state.get_node_count(): if String(state.get_node_type(i)) == "ScrollContainer": scroll_paths.append(paths[i]) assert_true(not scroll_paths.is_empty(), "%s has a ScrollContainer" % scene_path) # ...then require the deepest control to live under one of them. var wanted: String = DEEPEST_CONTROLS[scene_path] var found_path := "" for i in state.get_node_count(): if String(state.get_node_name(i)) == wanted: found_path = paths[i] break assert_true(found_path != "", "%s contains %s" % [scene_path, wanted]) if found_path == "": continue var scrolled := false for scroll_path in scroll_paths: if found_path.begins_with(String(scroll_path) + "/"): scrolled = true break assert_true(scrolled, "%s's %s is inside a ScrollContainer (at %s)" % [scene_path, wanted, found_path]) func test_menu_scroll_containers_follow_focus() -> void: # Without follow_focus, grab_focus() on a control below the fold (main_menu # focuses FreePlayButton on ready) leaves the view showing something else, # and controller navigation walks focus off-screen silently. for scene_path in DEEPEST_CONTROLS: var scene: PackedScene = load(scene_path) if scene == null: continue var state := scene.get_state() var checked := 0 for i in state.get_node_count(): if String(state.get_node_type(i)) != "ScrollContainer": continue checked += 1 assert_true( _property(state, i, "follow_focus", false) == true, "%s/%s has follow_focus" % [scene_path, state.get_node_name(i)] ) assert_true(checked > 0, "%s has at least one ScrollContainer to check" % scene_path) func test_menu_scroll_containers_do_not_scroll_horizontally() -> void: # Horizontal scrolling is disabled so content is clamped to the window # width instead of growing a second scrollbar — the dev bot dropdowns are # filled from filenames and would otherwise widen the whole menu. for scene_path in DEEPEST_CONTROLS: var scene: PackedScene = load(scene_path) if scene == null: continue var state := scene.get_state() for i in state.get_node_count(): if String(state.get_node_type(i)) != "ScrollContainer": continue assert_eq( _property(state, i, "horizontal_scroll_mode", ScrollContainer.SCROLL_MODE_AUTO), ScrollContainer.SCROLL_MODE_DISABLED, "%s/%s disables horizontal scrolling" % [scene_path, state.get_node_name(i)] )