simvx.core.testing.input_sim

InputSimulator – simulate keyboard/mouse/touch input for headless testing.

Each public method does three things in one call:

  1. State injection – writes into the Input state the tree being driven owns, matching the bytes the platform adapter would write (so Input.is_mouse_button_pressed() and friends see the press).

  2. Scene-tree event propagation – posts a TreeInputEvent to the active SceneTree so @on_input decorators fire.

  3. UI-tree event propagation – posts a UIInputEvent via tree.ui_input(...) so Control._on_gui_input handlers fire.

If no SceneTree is active (pure logic tests with no tree), steps 2 and 3 are silently skipped.

The keyboard path runs through :class:InputRouter, the same object every window backend feeds, so a simulated key has the shape a real one does: modifiers and the auto-repeat echo flag included, and the UI reached as well as @on_input. The state object is resolved per call from the tree, so a tree built with isolated_input=True receives its own input rather than the process-wide default.

Module Contents

Classes

InputSimulator

Simulate input events for headless testing.

Data

API

simvx.core.testing.input_sim.__all__

[‘InputSimulator’]

class simvx.core.testing.input_sim.InputSimulator(tree: simvx.core.scene_tree.SceneTree | None = None)[source]

Simulate input events for headless testing.

Drives the engine the same way real platform adapters do: state writes, scene-tree event propagation, and UI-tree event propagation, so a single sim.click(pos) call fires polling state, @on_input decorators, and Control._on_gui_input in lockstep. Keys go through

Class:

InputRouter itself, so a simulated key is indistinguishable from one a window backend produced.

The target SceneTree is either bound at construction time (when a test explicitly knows which tree to drive, as UITestHarness does) or resolved lazily via SceneTree.current() on each call (so simple one-tree scenarios work without plumbing).

Usage: from simvx.core.input import Key sim = InputSimulator() sim.press_key(Key.SPACE) runner.advance_frames(1) sim.release_key(Key.SPACE)

Initialization

press_key(key: simvx.core.input.Key | int, *, echo: bool = False) None[source]

Simulate a key press. Accepts Key enum or int.

The modifier flags on the event come from the keys currently held, so a chord is spelled by pressing its modifier first. echo=True marks an auto-repeat press: it reaches the UI, so a held key keeps repeating in a text field, and it is not propagated as an @on_input event, because a game action fires once per physical press.

release_key(key: simvx.core.input.Key | int) None[source]

Simulate a key release.

tap_key(key: simvx.core.input.Key | int) None[source]

Press now, schedule release for the next frame boundary.

Pressing AND releasing in the same Python tick lights up both is_action_just_pressed AND is_action_just_released on the same frame, which breaks edge-triggered game logic (PyDew Valley and the Tier-1 Balatro port both hit this). Instead, press immediately so the current frame’s tree.tick() sees is_action_just_pressed, then queue the release for the next frame boundary.

SceneRunner.advance_frames drains the queue at the end of every iteration (after Input._new_frame), so the typical flow sim.tap_key(); runner.advance_frames(2) observes is_action_just_pressed on the first iteration and is_action_just_released on the second. Callers driving frames outside SceneRunner can flush manually via

Meth:

flush_pending_releases.

classmethod flush_pending_releases() None[source]

Release every key and gamepad button queued by a tap since the last flush.

Writes the release into the Input state AND propagates a

Class:

TreeInputEvent to the active SceneTree so polling (is_action_just_released) and @on_input decorators both observe the release edge.

Snapshots the queues before iterating so a release handler that re-queues a release does not extend the current drain.

press_mouse(button: simvx.core.input.MouseButton | int = MouseButton.LEFT, position: tuple[float, float] | None = None) None[source]

Simulate mouse button press, optionally at a position.

release_mouse(button: simvx.core.input.MouseButton | int = MouseButton.LEFT) None[source]

Simulate mouse button release.

click(position: tuple[float, float], button: simvx.core.input.MouseButton | int = MouseButton.LEFT) None[source]

Click at a screen position (press + release).

move_mouse(x: float, y: float) None[source]

Move the mouse cursor to (x, y).

scroll(dx: float = 0.0, dy: float = -1.0) None[source]

Simulate scroll wheel. dy < 0 = scroll down, dy > 0 = scroll up.

press_gamepad(button: simvx.core.input.JoyButton | int) None[source]

Simulate a gamepad button press (single gamepad, no pad_id).

release_gamepad(button: simvx.core.input.JoyButton | int) None[source]

Simulate a gamepad button release (single gamepad, no pad_id).

tap_gamepad(button: simvx.core.input.JoyButton | int) None[source]

Press now, schedule the release for the next frame boundary.

The gamepad twin of :meth:tap_key, and it exists for the same reason: pressing and releasing in one tick means no frame ever observes the button held, so a handler reading held state never runs and both edges land together.

set_gamepad_axis(axis: simvx.core.input.JoyAxis | int, value: float) None[source]

Set a gamepad axis in the engine’s convention (single gamepad, no pad_id).

Sticks run -1.0 to 1.0 with +y down; triggers run 0.0 released to 1.0 fully pulled. Values arrive already normalised, as they do from a real backend, so a simulated resting trigger is 0.0 and not -1.0.

set_gamepad(pad_id: int = 0, *, buttons: dict[str, bool] | None = None, axes: dict[str, float] | None = None) None[source]

Publish one pad’s whole state, the way a platform adapter does per frame.

The ergonomic helpers above write the typed JoyButton / JoyAxis state the InputMap binding resolver reads. This writes the per-pad string-keyed state too, so the is_gamepad_pressed(0, "a") and get_gamepad_axis(0, "lt") polling API is drivable from a test without reaching past the public surface.

The named entries merge into the pad’s current snapshot; a pad this simulator has not seen starts neutral (every button up, every axis at zero). Unknown names raise, since a typo would otherwise read as a button nothing ever presses.

The pad counts as connected from here on and reads back through

Meth:

Input.get_connected_gamepads, but a real window backend’s poll will not prune it: a test’s pad does not vanish because the machine running the test has no controller plugged in.

Args: pad_id: Which pad to publish. buttons: Button names to set, e.g. {"a": True}. See the standard set both desktop backends report. axes: Axis names to set, e.g. {"lt": 0.5}.

touch_down(finger_id: int = 0, position: tuple[float, float] = (0, 0), pressure: float = 1.0) None[source]

Simulate a touch press (finger down).

touch_move(finger_id: int = 0, position: tuple[float, float] = (0, 0), pressure: float = 1.0) None[source]

Simulate a touch move (finger drag).

touch_up(finger_id: int = 0, position: tuple[float, float] = (0, 0)) None[source]

Simulate a touch release (finger up).

reset() None[source]

Reset all input state to defaults.

Also drains the class-level tap queues so a tap_key or tap_gamepad from an earlier test cannot fire a stale release the first time the next test advances a frame.