Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/main-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions hoppys-adventure-arcade/tests/menu_hoppy_smoke.gd
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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")
Expand Down
19 changes: 7 additions & 12 deletions hoppys-adventure-arcade/tests/menu_smoke.gd
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
extends SceneTree
## Run with --path <project> --script res://tests/menu_smoke.gd.
## Run with --path <project> --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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
26 changes: 26 additions & 0 deletions hoppys-adventure-arcade/tests/support/pointer.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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`, 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_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:
return viewport.get_visible_rect().end - Vector2(8, 8)
1 change: 1 addition & 0 deletions hoppys-adventure-arcade/tests/support/pointer.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://blamhp7yk5nqg
2 changes: 0 additions & 2 deletions tools/tests/suites.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading