simvx.core.testing.input_sim

InputSimulator – simulate keyboard, mouse, touch and pen input for headless testing.

Each public method does three things in one call:

Keyboard, mouse, fingers, a pen and scroll all go through :class:InputRouter, the same object every window backend feeds, so one sim.click(pos) writes the polled state, reaches the UI and fires @on_input in exactly the order and shape a real click does: modifiers and the auto-repeat echo flag included. A simulator that built its own events would be a second delivery path, and the difference between it and the real one is precisely what a test is there to catch.

If no SceneTree is active (pure logic tests with no tree), the router writes device state and delivers nothing, which is what a state-only test wants. The state object is resolved per call from the tree, so what the simulator presses lands in the delivered record of the tree under test and its actions resolve from it.

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(target: simvx.core.scene_tree.SceneTree | simvx.core.node.Node)[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.

It drives one tree, named at construction: input belongs to a tree, and a simulator that guessed would write into whichever tree an earlier test left current.

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

Initialization

Drive target: a tree, or a node whose tree is resolved on first use.

A node may be handed over before it is in a tree, so a simulator can be built beside a scene that app.run_headless has yet to mount; using it while the node is in no tree raises.

property router: simvx.core.input.router.InputRouter[source]

The router this simulator publishes through: its window.

A pointer belongs to its source, so a test that presses through the simulator and then wants that window to lose focus has to use the same one. Building a second router models a second window, which correctly cancels nothing the first one is holding.

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

Simulate a key press. Accepts a key or its platform code.

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 in the broadcast only an @on_input key handler declared with repeat=True hears it, because a game action fires once per physical press.

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

Simulate a key release.

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

Press now, schedule release for the next frame boundary.

Pressing AND releasing in the same Python tick lights up both just_pressed AND 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 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 just_pressed on the first iteration and 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 controller button queued by a tap since the last flush.

Writes the release into the Input state AND propagates a

Class:

KeyEvent to the active SceneTree so polling (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.DeviceButton | int = Mouse.LEFT, position: tuple[float, float] | None = None) → None[source]

Simulate mouse button press, optionally at a position.

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

Simulate mouse button release.

click(position: tuple[float, float], button: simvx.core.input.DeviceButton | int = Mouse.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), and deliver the motion now.

The engine coalesces motion into one event per frame, so a backend’s samples pile up until the frame’s input phase spends them. One simulated move is one frame’s worth of motion, and a test that has just moved the pointer expects it to have arrived, so the flush happens here rather than leaving every caller to step a frame for it.

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

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

press_controller(button: simvx.core.input.DeviceButton | int) → None[source]

Simulate a controller button press on one simulated pad: Controller.A, or the pad’s index.

release_controller(button: simvx.core.input.DeviceButton | int) → None[source]

Simulate a controller button release on the same pad.

tap_controller(button: simvx.core.input.DeviceButton | int) → None[source]

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

The controller 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_controller_axis(axis: simvx.core.input.controls.DeviceAxis | int, value: float) → None[source]

Set a controller axis in the engine’s convention (single controller, 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_controller(pad_id: int = 0, *, buttons: dict[str, bool] | dict[int, bool] | None = None, axes: dict[str, float] | dict[int, float] | None = None, recognised: bool = True, name: str | None = None, guid: str = '') → simvx.core.input.devices.ControllerDevice[source]

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

The ergonomic helpers above drive one nameless pad. This publishes a pad of its own, the backend’s pad_id, and returns the

Class:

~simvx.core.input.devices.ControllerDevice Input.controllers holds for it, so a test can exercise local multiplayer, a pad arriving and a pad leaving 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 Input.controllers, with its

Attr:

~simvx.core.input.enums.ControllerSource.SIMULATED source, 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: The backend’s id for the 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}. recognised: False publishes a controller no database recognised, a flight stick say, whose buttons and axes are keyed by the driver’s index ({3: True}) because it has no names. name: What the device calls itself; "Simulated pad <id>" by default. guid: The device’s guid, which the player’s saved mapping is filed under.

pointer_id(kind: simvx.core.input.DeviceType | str, backend_id: int = 0, *, device_id: int = 0) → int | None[source]

The pointer id the router gave a simulated finger or pen, while it is live.

Pointer ids are opaque, so a test that put finger 0 down asks here which id Input.pointers and the events carry for it.

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

Put a finger on the glass, through the router, and return its pointer id.

pressure left unset is a screen that cannot measure it, which reads as 1.0 in contact. The id is fresh each time a finger touches down, as it is on a real screen.

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

Drag a finger that is down.

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

Lift a finger.

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

Have the system take a finger away mid-gesture, as an edge swipe does.

pen(phase: simvx.core.input.PointerPhase | str, position: tuple[float, float], *, pen_id: int = 0, button: simvx.core.input.DeviceButton | int = Pointer.PRIMARY, pressure: float | None = None, tilt: tuple[float, float] | None = None, twist: float | None = None, distance: float | None = None, tangential_pressure: float | None = None, tool: simvx.core.input.PointerTool | str = PointerTool.TIP, tool_id: int = 0) → int | None[source]

One pen sample through the router; returns the pen’s pointer id while it is in range.

MOVE with nothing pressed is the pen hovering; DOWN and UP with the primary button are the tip touching and lifting, and with Pen.BARREL_1 or an index a barrel button; ENTER and LEAVE bring it into and out of range. An axis left as None is one the simulated pen does not measure. The sample is delivered at once, as :meth:move_mouse delivers a move.

reset() → None[source]

Reset all input state to defaults.

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