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. A single call writes Input state, fires @on_input decorators (via tree.propagate_input), and routes through the UI tree (via tree.ui_input), 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, MouseButton

sim = InputSimulator()
runner = SceneRunner()
runner.load(MyScene())

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), MouseButton.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

press_key(key, echo=False)

Hold a key down (echo=True is an auto-repeat press)

release_key(key)

Release a key

tap_key(key)

Press now, release at the next frame boundary

press_mouse(button=MouseButton.LEFT, position=None)

Press mouse button

release_mouse(button=MouseButton.LEFT)

Release mouse button

click(position, button=MouseButton.LEFT)

Press + release at position

move_mouse(x, y)

Move cursor

scroll(dx=0, dy=-1)

Scroll wheel

press_gamepad(button) / release_gamepad(button)

Hold or release a pad button

tap_gamepad(button)

Press now, release at the next frame boundary

set_gamepad_axis(axis, value)

Set one axis on the typed state

set_gamepad(pad_id=0, buttons=..., axes=...)

Publish a whole pad snapshot by name

reset()

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_gamepad writes the per-pad string-keyed state as a platform adapter does, so Input.is_gamepad_pressed(0, "a") and Input.get_gamepad_axis(0, "lt") are drivable from a test. Named entries merge into the pad’s current snapshot; an unknown name raises.

sim.set_gamepad(0, buttons={"a": True}, axes={"lt": 0.5})

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

scene_diff(before, after)

List of human-readable changes between snapshots

scene_describe(root)

Tree-format string of all nodes and properties

ui_describe(root)

UI-focused tree format

NodeCounter.count(root)

Dict of {type_name: count}

NodeCounter.total(root)

Total node count

FrameTimer

Measure frame times (average_ms, max_ms, fps)

Example self-checks and their exit codes

Examples carry a headless self-check behind --test. It has three possible outcomes, and each gets its own process exit code:

Code

Meaning

0

The self-check ran and every claim held

1

The self-check ran and something it asserts is broken

2

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.