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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
This commit is contained in:
@@ -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/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/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/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/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_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
|
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ echo ""
|
|||||||
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
||||||
"$GODOT" --headless --script tests/touch_controls_test.gd
|
"$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 ""
|
||||||
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
||||||
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
"$GODOT" --headless --script tests/rigid_skin_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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cp5j0gl3xbtha
|
||||||
Reference in New Issue
Block a user