# 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)