From f11452292a253590aa3469446722c61d81144338 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 17:00:51 -0700 Subject: [PATCH 1/2] test(menu): run the hover-driven menu smoke tests headless [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Closes #56. menu_smoke and menu_hoppy_smoke were skipped in CI because their hover checks failed headless; everything else in them passed. Three causes: the 64x64 headless window's stretch transform scales a pointer event's position and the menu recenters in a 1280x1280 visible area, so fixed coordinates miss the buttons; Input.parse_input_event() never hovers anything headless; and push_input() is delivered at once while parsed events wait a frame, so the D-pad release sent just before re-selected the brawler after the hover. - tests/support/pointer.gd hovers a control at its actual rect with viewport.push_input(event, true), after flushing buffered input, the same way in rendered and headless runs. - Both suites use it; menu_smoke finishes with AudioService.settle(). - tools/tests/suites.toml no longer skips them. - CONTRIBUTING and docs/main-menu.md say hover works headless. --- CONTRIBUTING.md | 2 +- docs/main-menu.md | 2 +- .../tests/menu_hoppy_smoke.gd | 8 +++--- hoppys-adventure-arcade/tests/menu_smoke.gd | 19 ++++++-------- .../tests/support/pointer.gd | 25 +++++++++++++++++++ .../tests/support/pointer.gd.uid | 1 + tools/tests/suites.toml | 2 -- 7 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 hoppys-adventure-arcade/tests/support/pointer.gd create mode 100644 hoppys-adventure-arcade/tests/support/pointer.gd.uid diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c98ee44d..f5f930fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ python3 tools/tests/run_tests.py -k runner # suites whose name python3 tools/tests/run_tests.py --list # what runs and what is skipped ``` -- **Every new `.gd` file under `tests/` that `extends SceneTree` or `MainLoop` runs automatically.** Shared helpers that extend anything else, such as `tests/support/`, are never run as suites. There's no list to update. To keep a suite out of CI (it needs a GPU, a window or pointer hover), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. +- **Every new `.gd` file under `tests/` that `extends SceneTree` or `MainLoop` runs automatically.** Shared helpers that extend anything else, such as `tests/support/`, are never run as suites. There's no list to update. To keep a suite out of CI (it needs a GPU or a window), add it to `[skip]` in [`tools/tests/suites.toml`](tools/tests/suites.toml) with the reason. Pointer hover works headless through [`tests/support/pointer.gd`](hoppys-adventure-arcade/tests/support/pointer.gd); `Input.parse_input_event()` never hovers anything in a headless run. - **A suite fails if it exits nonzero, times out, or prints an engine or script error or an exit-time leak.** Suites run with `--verbose`, so a leak names the objects involved. The one tolerated shape is the engine's audio-teardown race: only `AudioStream*` playbacks and audio files, reported as a note. Anything else that leaks fails. That includes `ERROR:`, `SCRIPT ERROR`, parse errors and "leaked at exit". If a test provokes an engine error on purpose, list a pattern for it under `[expected_errors]`; the runner fails if that pattern stops matching. - **Finish a suite with `await root.get_node("AudioService").settle()` before `quit()`.** It stops the shared music and waits until the audio thread has released it. A fixed delay is not enough under parallel load. - Each suite gets a private user-data root (`HOME`, the XDG directories, and `APPDATA` on Windows), so `user://` files (settings, leaderboards) never touch your real ones. The runner checks this with Godot before starting and refuses to run if `user://` would escape. Suites listed under `[serial]` (wall-clock budgets, or suites that occupy every core) run alone after the parallel batch. Results are written to `build/test-results/summary.json`. diff --git a/docs/main-menu.md b/docs/main-menu.md index 93a9a1cf..eaa419d7 100644 --- a/docs/main-menu.md +++ b/docs/main-menu.md @@ -55,7 +55,7 @@ Updated 2026-10-08: all menu previews now use current stage artwork or captured /Applications/Godot.app/Contents/MacOS/Godot --path hoppys-adventure-arcade --script res://tests/menu_smoke.gd ``` -The behavioral smoke test covers preview timing and preservation, rapid input coalescing, bounded preview count, all thirteen previews, modal focus restoration, invalid routes, duplicate navigation, all three mode round trips, and audio player counts. It also dispatches keyboard, simulated gamepad, and mouse events. A rendered run writes menu/settings screenshots to `/tmp` for layout inspection. It uses the normal settings path, so run against a disposable user profile if preserving settings during tests matters. +The behavioral smoke test covers preview timing and preservation, rapid input coalescing, bounded preview count, all thirteen previews, modal focus restoration, invalid routes, duplicate navigation, all three mode round trips, and audio player counts. It also dispatches keyboard, simulated gamepad, and mouse events. Mouse hover goes through `tests/support/pointer.gd`, so it and the character check below run headless in CI. A rendered run writes menu/settings screenshots to `/tmp` for layout inspection. It uses the normal settings path, so run against a disposable user profile if preserving settings during tests matters. Physical controller feel, audio loop quality, and Windows/Linux exports require later hands-on checks; no export presets or platform builds were added. diff --git a/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd b/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd index 4144fbb7..1f4546de 100644 --- a/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd +++ b/hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd @@ -1,4 +1,7 @@ extends SceneTree +## Hoppy's reactions on the main menu. Runs headless in CI; a rendered run also saves screenshots. +## Pointer moves go through tests/support/pointer.gd so they hover headless. +const Pointer := preload("res://tests/support/pointer.gd") var failures := 0 func _initialize() -> void: call_deferred("run") @@ -20,10 +23,7 @@ func run() -> void: check(hoppy.sprite.animation == &"idle", "Default Hoppy idles in 2D") check(hoppy.runner_view.render_target_update_mode == SubViewport.UPDATE_DISABLED, "Hidden 3D render disabled") await capture("/tmp/menu-hoppy-2d.png") - var mouse := InputEventMouseMotion.new() - mouse.position = Vector2(200, 430) - mouse.relative = Vector2(10, 10) - Input.parse_input_event(mouse) + Pointer.hover(menu.buttons[1]) await wait(1) check(hoppy.runner_image.visible and hoppy.runner_image.modulate.a == 1, "Runner shows 3D") check(hoppy.sprite.modulate.a == 0, "2D fades away") diff --git a/hoppys-adventure-arcade/tests/menu_smoke.gd b/hoppys-adventure-arcade/tests/menu_smoke.gd index 7f8441cf..e29e5b44 100644 --- a/hoppys-adventure-arcade/tests/menu_smoke.gd +++ b/hoppys-adventure-arcade/tests/menu_smoke.gd @@ -1,5 +1,7 @@ extends SceneTree -## Run with --path --script res://tests/menu_smoke.gd. +## Run with --path --script res://tests/menu_smoke.gd. Runs headless in CI; a rendered run +## also saves screenshots. Pointer moves go through tests/support/pointer.gd so they hover headless. +const Pointer := preload("res://tests/support/pointer.gd") var failures := 0 func _initialize() -> void: call_deferred("run") @@ -97,16 +99,10 @@ func run() -> void: Input.parse_input_event(pad) check(root.gui_get_focus_owner() == current_scene.buttons[2], "Gamepad D-pad moves focus") check(current_scene.showcase.focus_group == 2, "Gamepad focus selects brawler") - var mouse := InputEventMouseMotion.new() - mouse.position = Vector2(200, 430) - mouse.relative = Vector2(10, 10) - Input.parse_input_event(mouse) + Pointer.hover(current_scene.buttons[1]) await process_frame check(current_scene.showcase.focus_group == 1, "Mouse hover overrides old gamepad focus") - mouse = InputEventMouseMotion.new() - mouse.position = Vector2(620, 680) - mouse.relative = Vector2(420, 250) - Input.parse_input_event(mouse) + Pointer.move(root, Pointer.corner(root), Vector2(420, 250)) await process_frame check(current_scene.showcase.focus_group == -1, "Pointer exit releases mode preview") current_scene.showcase.select_group(-1) @@ -130,7 +126,6 @@ func run() -> void: # The verdict is already printed; a missing or renamed autoload must not stop quit(failures) # from running, or the process hangs instead of reporting. var shutdown_audio := root.get_node_or_null("AudioService") - if shutdown_audio != null and shutdown_audio.has_method("shutdown"): - shutdown_audio.shutdown() - await wait(0.3) + if shutdown_audio != null and shutdown_audio.has_method("settle"): + await shutdown_audio.settle() quit(failures) diff --git a/hoppys-adventure-arcade/tests/support/pointer.gd b/hoppys-adventure-arcade/tests/support/pointer.gd new file mode 100644 index 00000000..190e446a --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/pointer.gd @@ -0,0 +1,25 @@ +extends RefCounted +## Pointer input that hovers controls in headless runs too. Input.parse_input_event() never hovers +## anything headless: the 64x64 headless window's stretch transform scales the event's position, +## and the menu recenters itself in the visible area, so fixed coordinates miss the buttons. +## Pushing the motion to the viewport in its own coordinates, aimed at a control's actual rect, +## hovers it the same way in rendered and headless runs. + +## Move the pointer to `point` in the viewport's coordinates, having moved by `relative`. Events +## sent with Input.parse_input_event() wait for the next frame, while push_input() delivers at +## once, so pending ones are flushed first to keep the order the suite sent them in. +static func move(viewport: Viewport, point: Vector2, relative := Vector2(10, 10)) -> void: + Input.flush_buffered_events() + var motion := InputEventMouseMotion.new() + motion.position = point + motion.global_position = point + motion.relative = relative + viewport.push_input(motion, true) + +## Move the pointer onto the middle of `control`. +static func hover(control: Control) -> void: + move(control.get_viewport(), control.get_global_rect().get_center()) + +## The bottom-right corner of the visible area, which no menu button covers. +static func corner(viewport: Viewport) -> Vector2: + return viewport.get_visible_rect().end - Vector2(8, 8) diff --git a/hoppys-adventure-arcade/tests/support/pointer.gd.uid b/hoppys-adventure-arcade/tests/support/pointer.gd.uid new file mode 100644 index 00000000..350abbe9 --- /dev/null +++ b/hoppys-adventure-arcade/tests/support/pointer.gd.uid @@ -0,0 +1 @@ +uid://blamhp7yk5nqg diff --git a/tools/tests/suites.toml b/tools/tests/suites.toml index b6d27c03..cf8d12ef 100644 --- a/tools/tests/suites.toml +++ b/tools/tests/suites.toml @@ -9,8 +9,6 @@ capture_runner_menu_previews = "Writes menu preview screenshots; needs a window perf_probe = "Measures GPU frame times, which are meaningless headless. Run it locally." runner_perf_probe = "Measures runner frame times on a GPU. Run it locally." runner_pickup_hitch = "Measures GPU hitches during pickups. Run it locally." -menu_smoke = "Drives the menu with pointer hover, which a headless run never produces." -menu_hoppy_smoke = "Drives the menu with pointer hover, which a headless run never produces." capture_code_city_bosses = "Writes the Code City boss stills into docs/previews/; a graphical run only." exit_leak = "Needs a --scenario per process; CI runs every scenario through tools/checks/exit_leak.py." "support/session_log_quit" = "Child process for booth_session_log: it quits through AppRouter on purpose." From ebb85df429a249f3ca906aeb71f496645b853089 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Thu, 15 Oct 2026 20:45:08 -0700 Subject: [PATCH 2/2] test(menu): aim the pointer in viewport coordinates [Agent: Claude Code | Model: Opus 5.5 | Thinking: High | Acting on behalf of: Erik | Skill: none] Addresses CodeRabbit review 5542805293 on #87: hover() maps the control's centre through get_global_transform_with_canvas(), so a control under a canvas layer or camera transform is still hit. --- hoppys-adventure-arcade/tests/support/pointer.gd | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hoppys-adventure-arcade/tests/support/pointer.gd b/hoppys-adventure-arcade/tests/support/pointer.gd index 190e446a..25f9db44 100644 --- a/hoppys-adventure-arcade/tests/support/pointer.gd +++ b/hoppys-adventure-arcade/tests/support/pointer.gd @@ -16,9 +16,10 @@ static func move(viewport: Viewport, point: Vector2, relative := Vector2(10, 10) motion.relative = relative viewport.push_input(motion, true) -## Move the pointer onto the middle of `control`. +## Move the pointer onto the middle of `control`, in viewport coordinates (through any canvas layer +## or camera transform above it). static func hover(control: Control) -> void: - move(control.get_viewport(), control.get_global_rect().get_center()) + move(control.get_viewport(), control.get_global_transform_with_canvas() * (control.size * 0.5)) ## The bottom-right corner of the visible area, which no menu button covers. static func corner(viewport: Viewport) -> Vector2: