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¶
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_inputdecorators, andControl.on_gui_inputin lockstep. Keys go through- Class:
InputRouteritself, 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_headlesshas 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=Truemarks 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_inputkey handler declared withrepeat=Truehears it, because a game action fires once per physical press.
- 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_pressedANDjust_releasedon 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’stree.tick()seesjust_pressed, then queue the release for the next frame boundary.SceneRunner.advance_framesdrains the queue at the end of every iteration (afterInput._new_frame), so the typical flowsim.tap_key(); runner.advance_frames(2)observesjust_pressedon the first iteration andjust_releasedon 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
Inputstate AND propagates a- Class:
KeyEventto the activeSceneTreeso polling (just_released) and@on_inputdecorators 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.ControllerDeviceInput.controllersholds 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.SIMULATEDsource, 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.pointersand 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.
pressureleft 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_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.
MOVEwith nothing pressed is the pen hovering;DOWNandUPwith the primary button are the tip touching and lifting, and withPen.BARREL_1or an index a barrel button;ENTERandLEAVEbring 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_mousedelivers a move.