From 994ce3be7380a5e33b505c4dd7ee642c2c9f9b34 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Sat, 5 Sep 2026 00:01:06 +0300 Subject: [PATCH] Add headless UI-layout robustness test Asserts computed Control geometry instead of pixels: HUD/menu stay on-screen, text fits its box, tap targets >=44px, and the options scroll can reach its overflow -- across five resolutions plus a +40% pseudo-locale pass. Wired into run_tests.sh and the CLAUDE.md test list. 16/16 pass headless. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1 --- CLAUDE.md | 1 + run_tests.sh | 4 + tests/ui_layout_test.gd | 243 ++++++++++++++++++++++++++++++++++++ tests/ui_layout_test.gd.uid | 1 + 4 files changed, 249 insertions(+) create mode 100644 tests/ui_layout_test.gd create mode 100644 tests/ui_layout_test.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index dd4a052..d01d2f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s godot --headless --script tests/touch_controls_test.gd # joystick self-heals: relayout/focus-out/vanished-touch never strand a stuck stick, ~2 s +godot --headless --script tests/ui_layout_test.gd # UI layout invariants (no pixels): HUD/menu stay on-screen, text fits, tap targets ≥44px, scroll works — across resolutions + pseudo-locale, ~6 s godot --headless --script tests/rigid_skin_test.gd # web mesh conversion keeps every material (no bald / recoloured matadors), ~2 s godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s diff --git a/run_tests.sh b/run_tests.sh index 050c0a0..5f4cd5c 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -26,6 +26,10 @@ echo "" echo "=== Touch control tests (joystick self-heals, never strands) ===" "$GODOT" --headless --script tests/touch_controls_test.gd +echo "" +echo "=== UI layout tests (HUD stays on-screen, text fits, tap targets, scroll — across resolutions) ===" +"$GODOT" --headless --script tests/ui_layout_test.gd + echo "" echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ===" "$GODOT" --headless --script tests/rigid_skin_test.gd diff --git a/tests/ui_layout_test.gd b/tests/ui_layout_test.gd new file mode 100644 index 0000000..6e88d76 --- /dev/null +++ b/tests/ui_layout_test.gd @@ -0,0 +1,243 @@ +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://scene.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://MainMenu.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) diff --git a/tests/ui_layout_test.gd.uid b/tests/ui_layout_test.gd.uid new file mode 100644 index 0000000..d94eb75 --- /dev/null +++ b/tests/ui_layout_test.gd.uid @@ -0,0 +1 @@ +uid://cp5j0gl3xbtha