extends SceneTree ## UI layout robustness — asserts computed Control geometry instead of comparing pixels, so it ## runs headless and deterministically. It guards what a golden screenshot can't articulate: ## HUD elements staying on-screen, text not overflowing its box (even under a wider pseudo-locale), ## and touch targets staying thumb-sized. Every check is re-run across several resolutions, so a ## layout that only holds at one aspect ratio is caught. ## Run: godot --headless --script tests/ui_layout_test.gd const MIN_TAP := 44.0 # smallest comfortable touch target (Apple HIG 44pt) const LANDSCAPE := [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1080)] const PORTRAIT := [Vector2i(1080, 2340), Vector2i(720, 1280)] const EDGE_TOL := 1.5 # sub-pixel rounding slack for containment var _pass := 0 var _fail := 0 var _warn := 0 func _init() -> void: _run.call_deferred() func _ok(cond: bool, msg: String) -> void: if cond: _pass += 1 print(" PASS: ", msg) else: _fail += 1 print(" FAIL: ", msg) # Passes when no offenders were found; otherwise fails and lists each one so the failure names # the actual element and rect, not just "layout broke". func _ok_empty(bad: Array, msg: String) -> void: if bad.is_empty(): _pass += 1 print(" PASS: ", msg) else: _fail += 1 print(" FAIL: ", msg) for b: String in bad: print(" · ", b) # Overflow under a pseudo-locale is reported, not failed: until the game is actually localised it # is a forward-looking warning. Promote _warn_empty → _ok_empty here the day translations land. func _warn_empty(bad: Array, msg: String) -> void: if bad.is_empty(): print(" ok: ", msg) else: _warn += bad.size() print(" WARN: ", msg) for b: String in bad: print(" · ", b) func _settle() -> void: await process_frame await process_frame await process_frame func _collect(node: Node, acc: Array) -> void: if node is Control and (node as Control).is_visible_in_tree(): acc.append(node) for child: Node in node.get_children(): _collect(child, acc) func _rect_inside(r: Rect2, vp: Vector2) -> bool: return r.position.x >= -EDGE_TOL and r.position.y >= -EDGE_TOL \ and r.end.x <= vp.x + EDGE_TOL and r.end.y <= vp.y + EDGE_TOL func _audit_containment(ctrls: Array, vp: Vector2) -> Array: var bad: Array = [] for c: Control in ctrls: if c.size.x <= 0.0 or c.size.y <= 0.0: continue var r := c.get_global_rect() if not _rect_inside(r, vp): bad.append("%s escapes viewport: %s" % [c.name, r]) return bad # A Label whose text can visibly clip: no wrapping, no clip flag, no ellipsis trimming — so if its # content needs more width than it was given, glyphs spill. Exactly the localisation-overflow case. func _hard_label(c: Control) -> bool: if not (c is Label): return false var l := c as Label return l.text != "" and l.autowrap_mode == TextServer.AUTOWRAP_OFF \ and not l.clip_text and l.text_overrun_behavior == TextServer.OVERRUN_NO_TRIMMING func _audit_overflow(ctrls: Array) -> Array: var bad: Array = [] for c: Control in ctrls: if not _hard_label(c): continue var need := (c as Label).get_combined_minimum_size().x if need > c.size.x + 1.0: bad.append("%s needs %.0fpx, has %.0fpx: \"%s\"" % [c.name, need, c.size.x, (c as Label).text.left(24)]) return bad func _audit_taps(ctrls: Array) -> Array: var bad: Array = [] for c: Control in ctrls: if not (c is BaseButton): continue if c.size.x < MIN_TAP or c.size.y < MIN_TAP: bad.append("%s is %.0fx%.0f (< %.0f)" % [c.name, c.size.x, c.size.y, MIN_TAP]) return bad # The on-screen thumb cluster draws + hit-tests from raw rects (not Control children), so audit its # own single source of truth: the ability button rects, the menu button, and the joystick diameter. func _audit_touch_cluster(tc: Node) -> Array: var bad: Array = [] var rects: Array = tc.get("_ability_rects") for i in rects.size(): var r: Rect2 = rects[i] if r.size.x < MIN_TAP or r.size.y < MIN_TAP: bad.append("ability button %d is %.0fx%.0f" % [i, r.size.x, r.size.y]) var mr: Rect2 = tc.get("_menu_rect") if mr.size.x < MIN_TAP or mr.size.y < MIN_TAP: bad.append("menu button is %.0fx%.0f" % [mr.size.x, mr.size.y]) var stick_d: float = float(tc.get("_stick_radius")) * 2.0 if stick_d < MIN_TAP: bad.append("joystick diameter is %.0f" % stick_d) return bad func _find_touch(node: Node) -> Node: if node.get("_ability_rects") != null and node.has_method("_relayout"): return node for child: Node in node.get_children(): var found := _find_touch(child) if found != null: return found return null func _boot_shell(touch: bool) -> Node: var dp: Node = root.get_node("/root/DP") dp.call("set_value", "force_touch_controls", touch) var scene: Node = load("res://levels/game_shell.tscn").instantiate() root.add_child(scene) current_scene = scene await create_timer(0.8).timeout # let the shell build the level + HUD return scene func _run() -> void: (root.get_node("/root/Run") as Node).call("ensure_run") # ── Desktop HUD across landscape resolutions ──────────────────────────────── var scene: Node = await _boot_shell(false) var hud: Node = scene.get_node("HUD") for size: Vector2i in LANDSCAPE: root.size = size await _settle() var ctrls: Array = [] _collect(hud, ctrls) _ok_empty(_audit_containment(ctrls, Vector2(size)), "desktop HUD stays on-screen @ %s" % size) _ok_empty(_audit_overflow(ctrls), "desktop HUD text fits its boxes @ %s" % size) # Wider-locale stress: expand every auto-translated string and re-check overflow (informational). ProjectSettings.set_setting("internationalization/pseudolocalization/replace_with_accents", true) ProjectSettings.set_setting("internationalization/pseudolocalization/expansion_ratio", 0.4) TranslationServer.set_pseudolocalization_enabled(true) await _settle() var loc_ctrls: Array = [] _collect(hud, loc_ctrls) _warn_empty(_audit_overflow(loc_ctrls), "desktop HUD text fits under a +40% pseudo-locale") TranslationServer.set_pseudolocalization_enabled(false) scene.queue_free() await _settle() # ── Touch HUD across portrait resolutions ─────────────────────────────────── var tscene: Node = await _boot_shell(true) var thud: Node = tscene.get_node("HUD") var tc := _find_touch(thud) _ok(tc != null, "touch control cluster is present in the touch HUD") for size: Vector2i in PORTRAIT: root.size = size await _settle() if tc != null: tc.call("_relayout") var ctrls: Array = [] _collect(thud, ctrls) _ok_empty(_audit_containment(ctrls, Vector2(size)), "touch HUD stays on-screen @ %s" % size) _ok_empty(_audit_overflow(ctrls), "touch HUD text fits its boxes @ %s" % size) _ok_empty(_audit_taps(ctrls), "touch HUD buttons are ≥ %.0fpx @ %s" % [MIN_TAP, size]) if tc != null: _ok_empty(_audit_touch_cluster(tc), "thumb cluster targets are ≥ %.0fpx @ %s" % [MIN_TAP, size]) tscene.queue_free() await _settle() # ── Main menu: the one scrollable panel actually scrolls when its content overflows ───────── # Short viewport so the rebind list can't fit, then open options the way the button does, so we # measure the real panel laid out — not a hidden one whose scrollbar hasn't been computed yet. root.size = Vector2i(1024, 600) var menu: Node = load("res://ui/main_menu.tscn").instantiate() root.add_child(menu) await create_timer(0.3).timeout if menu.has_method("_on_options_pressed"): menu.call("_on_options_pressed") await _settle() var scrolls: Array = [] _collect_scrolls(menu, scrolls) var exercised := false for sc: ScrollContainer in scrolls: if not sc.is_visible_in_tree() or sc.size.y <= 0.0: continue var content_h := 0.0 for ch: Node in sc.get_children(): if ch is Control: content_h = maxf(content_h, (ch as Control).get_combined_minimum_size().y) if content_h <= sc.size.y + 1.0: continue exercised = true var sb := sc.get_v_scroll_bar() _ok(sb.max_value - sb.page > 1.0, "menu options scroll '%s' can reach its overflow" % sc.name) if not exercised: print(" ok: no overflowing scroll panel to exercise") menu.queue_free() _finish() func _collect_scrolls(node: Node, acc: Array) -> void: if node is ScrollContainer: acc.append(node) for child: Node in node.get_children(): _collect_scrolls(child, acc) func _finish() -> void: print("Results: %d passed, %d failed, %d locale warnings" % [_pass, _fail, _warn]) quit(1 if _fail > 0 else 0)