spaceship/tests/test_hud_layout.gd
Loïc Wisniewski 6a62b6f5f8 Derive the context action row from the build button, not a fixed y
Seating the HUD columns below the resource readout moved the build button
from the scene-authored 160 to 288. _layout_context_buttons() then wrote the
action row straight back to a hardcoded 324 on the next context change - and
it runs on the first frame, so the action button overlapped the build button
immediately.

Same shape of bug as the overlap that seating fixed: a literal coordinate
that meant "directly below the thing above me" and silently stopped being
true when that thing moved. It now measures instead:

    row_top = build_button.position.y + build_button.size.y + HUD_ROW_GAP

Both the action button and the expanded context row derive from that, so
wherever the build button ends up, the row below follows.

tests/test_hud_layout.gd asserts the RELATIONSHIP rather than any
coordinate, so a freshly hardcoded y fails whatever value someone picks:
every HUD button clears the readout's real bottom, and no two
simultaneously-usable buttons overlap - checked at load, collapsed and
expanded, which is the path that broke. BuildingActionButton and
HangarButton are excluded as a deliberately exclusive pair.

Verified to fail against the previous code with the reported symptom:
build_button y288..436 overlapping action_button y324.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:09:44 +02:00

130 lines
4.4 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_hud_layout.gd
# The HUD's top-anchored button columns must not collide with the resource
# readout, and must not collide with each other.
#
# Both halves are regressions that actually shipped:
# - ResourceReadout builds one row per resource type AT RUNTIME, so the
# scene's authored offset_bottom understated it and the build button,
# at a hardcoded y, ended up underneath the resource list.
# - Seating the columns below the readout then exposed a second hardcoded
# y: _layout_context_buttons() wrote the action row back to 324 on the
# next context change, dropping it on top of the moved build button.
#
# Both were a literal coordinate that encoded "below the thing above me" and
# stopped being true when the thing above moved. So this asserts the
# RELATIONSHIP rather than any particular coordinate - a fresh hardcoded y
# fails here whatever value someone picks.
#
# Covers:
# A. every HUD button clears the resource readout's real bottom
# B. no two simultaneously-usable buttons overlap each other
# C. both still hold after _layout_context_buttons() runs, collapsed and
# expanded - the path that broke
# D. it holds with MORE resource types than the scene was authored for,
# which is the direction this drifts
var scene: Node
var controller: Node
var readout: Node
var stage: int = 0
var elapsed: float = 0.0
# Deliberately share a row: a building either offers its own action or a
# hangar, never both, so their rects coinciding is the design.
const EXCLUSIVE_PAIRS: Array = [["building_action_button", "hangar_button"]]
func _initialize() -> void:
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
readout = scene.get_node("UI/ResourceReadout")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
if not _check("as laid out at load"):
return true
# --- C. the path that broke it ------------------------------
controller.context_actions_expanded = false
controller._layout_context_buttons()
if not _check("after _layout_context_buttons() collapsed"):
return true
controller.context_actions_expanded = true
controller._layout_context_buttons()
if not _check("after _layout_context_buttons() expanded"):
return true
print("TEST_HUD_LAYOUT: PASS")
quit(0)
return true
return false
func _buttons() -> Dictionary:
return {
"build_button": controller.build_button,
"transfer_button": controller.transfer_button,
"action_button": controller.action_button,
"landing_button": controller.landing_button,
"building_action_button": controller.building_action_button,
"hangar_button": controller.hangar_button,
}
func _check(context: String) -> bool:
var buttons: Dictionary = _buttons()
var names: Array = buttons.keys()
# --- A. clear of the resource readout ---------------------------------
# content_bottom() is the readout's REAL extent, from the live resource
# list - not the stale height the scene happens to declare.
var readout_bottom: float = readout.content_bottom()
for name in names:
var button: Control = buttons[name]
if button == null:
continue
if button.position.y < readout_bottom:
return _fail("%s sits at y %.0f, inside the resource readout which reaches %.0f (%s)" \
% [name, button.position.y, readout_bottom, context])
# --- B. clear of each other -------------------------------------------
for i in range(names.size()):
for j in range(i + 1, names.size()):
var a_name: String = names[i]
var b_name: String = names[j]
if _is_exclusive(a_name, b_name):
continue
var a: Control = buttons[a_name]
var b: Control = buttons[b_name]
if a == null or b == null:
continue
if a.get_rect().intersects(b.get_rect()):
return _fail("%s %s overlaps %s %s (%s)" \
% [a_name, a.get_rect(), b_name, b.get_rect(), context])
print("clear of the readout (reaches %.0f) and of each other - %s" % [readout_bottom, context])
return true
func _is_exclusive(a: String, b: String) -> bool:
for pair in EXCLUSIVE_PAIRS:
if (a == pair[0] and b == pair[1]) or (a == pair[1] and b == pair[0]):
return true
return false
func _fail(message: String) -> bool:
print("TEST_HUD_LAYOUT: FAIL (%s)" % message)
quit(1)
return false