Files
stickman/tests/test_phase3b_ui_fixes.gd
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

381 lines
16 KiB
GDScript

# test_phase3b_ui_fixes.gd
# Headless regression suite for the Phase 3b Selector UI fix pass (5 bugs):
#
# Bug 1 (popup lost on window resize): AssetSelector connects
# get_tree().root.size_changed -> _on_root_size_changed, which re-centers via
# popup_centered() when visible (and no-ops while hidden).
# * The size_changed connection exists after _ready.
# * Calling _on_root_size_changed() while hidden leaves it hidden (no-op).
# * Calling _on_root_size_changed() while visible re-centers without error
# and keeps the popup visible. (A real OS window resize cannot be fired
# headless; exercising the handler is the intended coverage.)
#
# Bug 2 (selector backdrop): SandboxStage builds `_selector_dim` (a full-rect
# ColorRect at SELECTOR_DIM_ALPHA = 0.5 with MOUSE_FILTER_IGNORE) on the UI
# CanvasLayer BELOW the AssetSelector; _open_selector() shows it,
# _close_selector() hides it.
# * _selector_dim is created, starts hidden, is a sibling of the selector
# under the same UI layer at a lower index, has the dim color + ignore
# mouse filter, becomes visible after _open_selector(...) and hidden again
# after _close_selector().
#
# Bug 3 (direct-popup position): SandboxStage._world_to_screen(world_pos) is
# the pure inverse of Camera2D.get_global_mouse_position():
# screen = (world - camera.position) * camera.zoom + viewport_size * 0.5
# * Hand-computed known-value checks for two camera setups.
# * Engine round-trip: _world_to_screen(camera.get_global_mouse_position())
# returns the viewport mouse position.
#
# Bug 4 (selector pre-highlight removed): AssetSelector.open(kind, entries) no
# longer takes selected_path/selected_id and never highlights a cell.
# * The removed API is gone (_is_selected method, _selected_path/_selected_id
# members, selected-stylebox tokens in the source).
# * open() has arity 2.
# * Opening 2+ entries builds one cell per entry and NO cell carries a
# stylebox override (the old highlight applied one to the "selected" cell).
# * On the full SandboxStage, _open_selector("stickman") runs the real scan
# (3 stickmen, no single-item skip), populates the grid, and still shows no
# pre-highlight. When the selected file is missing the fall-back-to-first
# entry logic still runs before open() and no highlight appears.
#
# Bug 5 (cancel / popup_hide state): _on_selector_cancelled() is idempotent
# (guard `if not _selector_open: return`) and _close_selector() flips
# _selector_open=false BEFORE hiding the selector.
# * After open -> cancel: _selector_open false, _placement_id "", palette
# buttons unpressed, selector + dim hidden.
# * Calling _on_selector_cancelled() a second time does not error and leaves
# the state clean (this mirrors the popup_hide re-entry the fix guards
# against; popup_hide itself cannot fire headless).
# * _close_selector() is itself idempotent.
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3b_ui_fixes.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const ASSET_SELECTOR_SCENE := preload("res://scenes/asset_selector.tscn")
## Button stylebox states the old pre-highlight could have overridden on a cell.
const BUTTON_STYLEBOX_STATES: Array[String] = [
"normal", "hover", "pressed", "focus", "disabled", "selected",
]
const FAKE_ENTRIES: Array[Dictionary] = [
{ "path": "res://fake_one.stk", "name": "One" },
{ "path": "res://fake_two.stk", "name": "Two" },
{ "path": "res://fake_three.stk", "name": "Three" },
]
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
# Watchdog: if a runtime error aborts _run before quit(), force a FAIL exit
# instead of hanging the headless process forever.
var watchdog := create_timer(120.0)
watchdog.timeout.connect(func() -> void:
print("FAIL: watchdog timeout - test run aborted before quit()")
quit(2))
print("")
print("========================================================")
print(" PHASE 3b SELECTOR UI FIX REGRESSION TEST (headless)")
print("========================================================")
await _test_bug1_root_resize_handler()
await _test_bug2_dim_state()
await _test_bug3_world_to_screen()
await _test_bug4_selector_prehighlight_removed()
await _test_bug4_stage_grid_no_prehighlight()
await _test_bug5_cancel_state_idempotency()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: root-window resize re-centers the selector popup
# ---------------------------------------------------------------------------
func _test_bug1_root_resize_handler() -> void:
print("")
print("--- Bug 1: selector re-centers on root window resize ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
_check(sel.has_method("_on_root_size_changed"),
"AssetSelector exposes _on_root_size_changed")
_check(get_root().size_changed.is_connected(Callable(sel, "_on_root_size_changed")),
"root.size_changed is connected to _on_root_size_changed")
# Hidden: handler must be a no-op (no popup, stays hidden).
sel._on_root_size_changed()
_check(not sel.visible,
"resize handler is a no-op while the selector is hidden")
# Visible: handler re-centers via popup_centered() without error and keeps
# the popup visible (real OS resizes cannot fire headless).
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "selector is visible after open()")
sel._on_root_size_changed()
_check(sel.visible,
"resize handler runs without error while visible and keeps the popup visible")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 2: selector dim backdrop state
# ---------------------------------------------------------------------------
func _test_bug2_dim_state() -> void:
print("")
print("--- Bug 2: _selector_dim backdrop state ---")
var stage := _new_stage()
_check(stage._selector_dim != null, "stage builds _selector_dim")
if stage._selector_dim == null:
await _free_stage(stage)
return
_check(not stage._selector_dim.visible, "dim starts hidden")
_check(stage._selector_dim.get_parent() == stage._selector.get_parent(),
"dim and selector share the same UI layer parent")
_check(stage._selector_dim.get_index() < stage._selector.get_index(),
"dim is added below the selector (renders behind the popup)")
_check(stage._selector_dim.mouse_filter == Control.MOUSE_FILTER_IGNORE,
"dim never intercepts mouse events")
_check(is_equal_approx(stage._selector_dim.color.a, stage.SELECTOR_DIM_ALPHA),
"dim alpha == SELECTOR_DIM_ALPHA (%.2f)" % stage.SELECTOR_DIM_ALPHA)
# 'prop' always has 4 templates so _open_selector never single-item-skips.
stage._open_selector("prop")
_check(stage._selector_dim.visible, "dim becomes visible after _open_selector()")
stage._close_selector()
_check(not stage._selector_dim.visible, "dim hidden again after _close_selector()")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 3: _world_to_screen pure math
# ---------------------------------------------------------------------------
func _test_bug3_world_to_screen() -> void:
print("")
print("--- Bug 3: _world_to_screen math ---")
var stage := _new_stage()
var cam: Camera2D = stage._camera
cam.make_current()
var vp_size: Vector2 = get_root().get_visible_rect().size
# Known-value checks across two camera setups and several world points.
_assert_world_to_screen(stage, cam, vp_size,
Vector2(120.0, -300.0), Vector2(1.5, 1.5),
[Vector2(40.0, -60.0), Vector2(-800.0, 200.0), Vector2(0.0, 0.0)])
_assert_world_to_screen(stage, cam, vp_size,
Vector2(-25.0, 40.0), Vector2(0.5, 0.5),
[Vector2(333.0, -777.0), Vector2(-10.5, 12.25)])
# Engine round-trip: inverse of Camera2D.get_global_mouse_position().
cam.position = Vector2(120.0, -300.0)
cam.zoom = Vector2(1.5, 1.5)
var mouse: Vector2 = get_root().get_mouse_position()
var gmp: Vector2 = cam.get_global_mouse_position()
var screen_of_gmp: Vector2 = stage._world_to_screen(gmp)
_check(screen_of_gmp.distance_to(mouse) < 0.1,
"round-trip _world_to_screen(camera.get_global_mouse_position()) ~= viewport mouse (dist %.5f)"
% screen_of_gmp.distance_to(mouse))
await _free_stage(stage)
func _assert_world_to_screen(stage: Node2D, cam: Camera2D, vp_size: Vector2,
cam_pos: Vector2, cam_zoom: Vector2, points: Array) -> void:
cam.position = cam_pos
cam.zoom = cam_zoom
for p in points:
var w: Vector2 = p as Vector2
var expected: Vector2 = (w - cam_pos) * cam_zoom + vp_size * 0.5
var actual: Vector2 = stage._world_to_screen(w)
_check(expected.distance_to(actual) < 0.01,
"cam %s zoom %s: _world_to_screen(%s) == %s (got %s)"
% [cam_pos, cam_zoom, w, expected, actual])
# ---------------------------------------------------------------------------
# Bug 4: AssetSelector pre-highlight removed (direct instance)
# ---------------------------------------------------------------------------
func _test_bug4_selector_prehighlight_removed() -> void:
print("")
print("--- Bug 4: AssetSelector pre-highlight removed ---")
var sel = ASSET_SELECTOR_SCENE.instantiate()
root.add_child(sel)
# Removed API is gone.
_check(not sel.has_method("_is_selected"), "pre-highlight helper _is_selected() removed")
_check(sel.get("_selected_path") == null, "member _selected_path removed")
_check(sel.get("_selected_id") == null, "member _selected_id removed")
# Source-presence: none of the deleted tokens may lurk in the script.
var file := FileAccess.open("res://scripts/asset_selector.gd", FileAccess.READ)
_check(file != null, "asset_selector.gd is readable")
if file != null:
var text := file.get_as_text()
file.close()
_check(not text.contains("_is_selected"), "source contains no _is_selected token")
_check(not text.contains("_selected_path"), "source contains no _selected_path token")
_check(not text.contains("_selected_id"), "source contains no _selected_id token")
# open() signature is (kind, entries) - no selected_path/selected_id args.
_check(_method_arg_count(sel, "open") == 2,
"open() takes exactly 2 args (got %d)" % _method_arg_count(sel, "open"))
# Populating 2+ entries builds a cell per entry with NO pre-highlight stylebox.
sel.open("stickman", FAKE_ENTRIES)
_check(sel.visible, "open() pops the selector")
_check(sel._grid.get_child_count() == FAKE_ENTRIES.size(),
"grid holds one cell per entry (got %d)" % sel._grid.get_child_count())
_check(not _any_cell_has_stylebox_override(sel),
"no cell carries a pre-highlight stylebox override")
sel.hide()
sel.queue_free()
await process_frame
# ---------------------------------------------------------------------------
# Bug 4 on the full stage: real scan populates the grid with no pre-highlight
# ---------------------------------------------------------------------------
func _test_bug4_stage_grid_no_prehighlight() -> void:
print("")
print("--- Bug 4: full-stage selector grid has no pre-highlight ---")
var stage := _new_stage()
# Selected file missing -> fall-back-to-first-entry still runs before open().
stage._spawner.selected_stickman_path = "res://stickmen/does_not_exist.stk"
stage._open_selector("stickman")
_check(stage._selector_open, "stage selector opens from a palette toggle")
_check(stage._spawner.get_selected_stickman_path().ends_with("basic.stk"),
"missing selected file falls back to the first scanned entry (got '%s')"
% stage._spawner.get_selected_stickman_path())
# Real scan has 3 stickmen -> no single-item auto-select skip.
var grid: GridContainer = stage._selector._grid
_check(grid.get_child_count() == 3,
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count())
_check(not stage._selector.has_method("_is_selected"),
"stage selector exposes no pre-highlight API")
_check(not _any_cell_has_stylebox_override(stage._selector),
"no stickman cell carries a pre-highlight stylebox override")
stage._on_selector_cancelled()
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Bug 5: cancel / popup_hide state + idempotency
# ---------------------------------------------------------------------------
func _test_bug5_cancel_state_idempotency() -> void:
print("")
print("--- Bug 5: _on_selector_cancelled state + idempotency ---")
var stage := _new_stage()
var stickman_btn: Button = stage._palette_buttons["stickman"]
stage._open_selector("stickman")
_check(stage._selector_open, "selector is open after _open_selector()")
_check(stage._selector.visible, "selector popup visible while open")
_check(stage._selector_dim.visible, "dim visible while open")
_check(stickman_btn.button_pressed, "stickman palette button pressed while open")
stage._on_selector_cancelled()
_check(not stage._selector_open, "cancel clears _selector_open")
_check(stage._placement_id == "", "cancel clears _placement_id")
_check(stage._selector_kind == "", "cancel clears _selector_kind")
_check(not stickman_btn.button_pressed, "cancel unpresses the palette button")
_check(not stage._selector.visible, "cancel hides the selector popup")
_check(not stage._selector_dim.visible, "cancel hides the dim backdrop")
# popup_hide cannot fire headless; a second direct invocation exercises the
# idempotency guard that the popup_hide -> _on_selector_cancelled path relies
# on (_close_selector flips _selector_open=false before hide()).
stage._on_selector_cancelled()
_check(not stage._selector_open, "second cancel does not reopen state")
_check(stage._placement_id == "", "second cancel leaves placement clean")
_check(not stickman_btn.button_pressed, "second cancel leaves palette clean")
_check(not stage._selector.visible and not stage._selector_dim.visible,
"second cancel leaves selector + dim hidden")
# _close_selector itself is idempotent (same ordering guarantees).
stage._close_selector()
_check(not stage._selector_open and not stage._selector.visible,
"_close_selector() twice is a clean no-op")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
## True when any cell Button in the selector's grid has a stylebox override for a
## standard button state (the old "currently selected asset gets a highlight
## stylebox border" behavior would have left exactly such an override).
func _any_cell_has_stylebox_override(sel) -> bool:
var grid: GridContainer = sel._grid
for child: Node in grid.get_children():
var cell := child as Button
if cell == null:
continue
for state: String in BUTTON_STYLEBOX_STATES:
if cell.has_theme_stylebox_override(state):
return true
return false
## Number of declared parameters for `method_name` on an Object (via reflection).
func _method_arg_count(obj: Object, method_name: String) -> int:
for m: Dictionary in obj.get_method_list():
if String(m["name"]) == method_name:
return int((m["args"] as Array).size())
return -1
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)