Testing¶
SimVX provides headless testing tools for game logic, UI widgets, and input: no GPU required.
For visual/pixel-level testing with the Vulkan renderer, see Visual testing.
SceneRunner¶
SceneRunner drives the scene tree without a window. Load a node, advance frames, and inspect state:
from simvx.core.testing import SceneRunner
runner = SceneRunner(screen_size=(800, 600))
runner.load(MyGameScene())
runner.advance_frames(10)
player = runner.find("Player") # Find by name
enemies = runner.find_all(Enemy) # Find all of a type
assert player.health > 0
API¶
SceneRunner(screen_size=(800, 600))
runner.load(root_node) -> SceneRunner # Chainable
runner.advance_frames(count=1, dt=None) -> SceneRunner
runner.advance_time(seconds, dt=None) -> SceneRunner
runner.find(name_or_type, recursive=True) # str or type
runner.find_all(node_type) -> list[Node]
runner.snapshot() -> dict # Capture scene state
runner.root # Root node
runner.frame_count # Frames processed
runner.elapsed_time # Seconds elapsed
InputSimulator¶
InputSimulator drives the engine the same way real platform adapters do: it
publishes through InputRouter, the one door input comes through. A single
call writes Input state, routes through the UI tree, and fires @on_input
handlers, so widget tests, decorator tests, and polling tests all see the
click from one sim.click(pos):
from simvx.core.testing import InputSimulator, SceneRunner
from simvx.core import Key, Mouse
runner = SceneRunner()
runner.load(MyScene())
sim = InputSimulator(runner.tree) # the tree it drives
sim.tap_key(Key.SPACE) # Press + release
runner.advance_frames(1)
assert runner.find("Player").jumped
sim.click((400, 300)) # Left-click at position
sim.click((400, 300), Mouse.RIGHT) # Right-click variant
runner.advance_frames(1)
sim.move_mouse(500, 400) # Move cursor
sim.press_key(Key.W) # Hold key
runner.advance_frames(60) # Hold for 60 frames
sim.release_key(Key.W)
sim.reset() # Clear all pressed state
The simulator finds the target SceneTree via SceneTree.current() (the
most-recently activated tree). When multiple trees coexist: e.g. the
editor opens a new scene tab during ready(): bind explicitly:
sim = InputSimulator(tree=my_tree)
API¶
Method |
Description |
|---|---|
|
Hold a key down ( |
|
Release a key |
|
Press now, release at the next frame boundary |
|
Press mouse button |
|
Release mouse button |
|
Press + release at position |
|
Move cursor |
|
Scroll wheel |
|
Hold or release a pad button |
|
Press now, release at the next frame boundary |
|
Set one axis on the typed state |
|
Publish a whole pad snapshot by name |
|
One finger on the glass |
|
Clear all pressed state |
Keys go through InputRouter, the object every window backend feeds, so a
simulated key carries the modifier flags a real one does: press the modifier
first and the chord is spelled for you.
sim.press_key(Key.LEFT_CONTROL)
sim.press_key(Key.S) # reaches handlers and the UI as ctrl+s
set_controller publishes one pad’s whole snapshot as a platform adapter does and
returns the ControllerDevice that Input.controllers holds for it, with a
ControllerSource.SIMULATED source. Its first argument is the backend’s id for the
pad, not the engine’s key in Input.controllers. Named entries merge into the
pad’s current snapshot; an unknown name raises.
pad = sim.set_controller(0, buttons={"a": True}, axes={"lt": 0.5})
assert pad.pressed(Controller.A)
Isolating a test’s input¶
Input is three layers. The device record is process-wide and answers what the machine is doing
(held keys, the fingers on the glass, the pads, Input.capabilities); the delivered record and
the action layer belong to one tree, though every tree’s map sits on the project layer, which is
shared. So a test that plugs in a pad, holds a key or sets project input leaves it that way for the next test unless it says
otherwise, and a test isolates the process-wide state in time, not in space:
import pytest
from simvx.core import Input
from simvx.core.input import capabilities, devices
from simvx.core.input.map import set_project_input
@pytest.fixture(autouse=True)
def clean_input():
devices.reset(); capabilities.reset(); set_project_input({})
yield
devices.reset(); capabilities.reset(); set_project_input({})
devices.reset() takes the device record back to process start, capabilities.reset() does the
same for the declared flags, and set_project_input({}) empties the project layer that sits
under every tree’s own context. Input._reset() calls the first and clears the caller’s map, which
is the tree’s inside a tree span.
The ambient Input reads the tree in play: inside a tree’s span it is that tree’s, outside one
it is the tree most recently activated (SceneTree.current(), which set_root and change_scene
set). With no tree at all an action query (Input.pressed, Input.value and the rest) raises,
as the bare InputMap does, while a device fact (Input.is_key_pressed, Input.devices,
Input.capabilities) still answers, because the device record is the machine’s. A test that builds a tree and
never gives it a root is not the tree Input answers for: call set_root, or read tree.input
by name.
The keyboard path¶
InputSimulator drives the real one, the path a window feeds: it calls the router, which writes polled state, offers the
key to the UI and propagates the event, so focus, button activation and a key a focused control
consumes all behave as they do under a window. It takes a Key enum.
For pure UI tests, simvx.core.ui.testing.UITestHarness takes a key by written combo
(harness.press_key("ctrl+s")) and drives only the UI router. A test that presses through a
harness and then wants that window to lose focus calls harness.router.focus_lost(); a router
built by hand is a second window, and a second window cancels nothing the first is holding.
Scene snapshots¶
Compare scene state before and after an operation:
from simvx.core.testing import SceneRunner, scene_diff
runner = SceneRunner().load(MyScene())
before = runner.snapshot()
runner.advance_frames(100)
after = runner.snapshot()
changes = scene_diff(before, after)
for change in changes:
print(change) # e.g. "Player.position: (0, 0) -> (100, 50)"
Describe¶
scene_describe() prints a human-readable tree of all nodes with their properties:
from simvx.core.testing import scene_describe
print(scene_describe(runner.root))
# MyScene
# Player (Node2D) position=(100, 50) health=3
# Sprite (Sprite2D)
# Enemy (Node2D) position=(400, 300)
UITestHarness¶
For widget-level testing without a GPU. Wraps a root Control with a mock scene tree and draw-command logger:
from simvx.core.ui.testing import UITestHarness
from simvx.core import Button, VBoxContainer
root = VBoxContainer()
btn = Button(text="Click Me")
root.add_child(btn)
harness = UITestHarness(root)
harness.click(btn) # Click by widget reference
assert btn.pressed_count > 0
harness.type_text("Hello") # Type into focused widget
harness.press_key("escape")
harness.scroll(dy=-3)
Draw assertions¶
UITestHarness captures all draw commands in a DrawLog. Use it to verify rendering without a GPU:
harness.tick()
log = harness.draw_log
assert log.has_text("Click Me")
assert log.has_text_containing("Score")
texts = log.texts() # All rendered text strings
rects = log.rects_at(100, 50) # Rects drawn at a point
Utilities¶
Function/Class |
Description |
|---|---|
|
List of human-readable changes between snapshots |
|
Tree-format string of all nodes and properties |
|
UI-focused tree format |
|
Dict of |
|
Total node count |
|
Measure frame times ( |
Example self-checks and their exit codes¶
A game can carry a headless self-check behind --test; the examples in this
repository are tested from examples/tests/ instead. A self-check has three possible
outcomes, and each gets its own process exit code:
Code |
Meaning |
|---|---|
|
The self-check ran and every claim held |
|
The self-check ran and something it asserts is broken |
|
The self-check could not run here: a capability it needs is absent |
Without the third, an example needing an optional package (or a device this
machine has not got) either fails as though the mechanic were broken or passes
as though the fallback it measured were the thing under test. Raise
Unsupported to say so, and let run_selftest map the outcome:
from simvx.core.testing import Unsupported, run_selftest
def _selftest() -> bool:
...
if backend != "JoltPhysics":
raise Unsupported("install simvx-physics-jolt to check the Jolt half")
print("SELFTEST:", "PASS" if ok else "FAIL")
return ok
if "--test" in sys.argv:
sys.exit(run_selftest(_selftest))
Report the checks that DID run before raising: a check that ran on a fallback
still says something, and a failure among them is a genuine failure, so raise
only once nothing else has gone wrong. An exception that is not Unsupported
propagates, because an unexpected traceback is worth seeing in full.
API reference¶
See simvx.core.testing and simvx.core.ui.testing for the complete testing API.