- 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.
381 lines
16 KiB
GDScript
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)
|