Source code for 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`` singleton's typed state
sets, 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.
"""
from collections import deque
from ..events import TreeInputEvent
from ..input import Input, Key, MouseButton
from ..scene_tree import SceneTree
__all__ = ["InputSimulator"]
[docs]
class InputSimulator:
"""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.
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)
"""
# Class-level pending-release queue. ``tap_key`` appends an int keycode here
# so the release fires on the NEXT frame boundary (preserving the
# ``is_action_just_pressed`` edge for the current frame and the
# ``is_action_just_released`` edge for the next). Drained by
# ``flush_pending_releases()``: ``SceneRunner.advance_frames`` invokes it
# at the end of every iteration (after ``Input._new_frame``). Class-level
# so callers can spin up ``InputSimulator()`` ad-hoc without bookkeeping.
_pending_releases: deque[int] = deque()
def __init__(self, tree: SceneTree | None = None):
# If ``tree`` is ``None``, ``_tree()`` falls back to
# ``SceneTree.current()``: the most recently-activated tree. Pass
# an explicit tree when multiple trees coexist (e.g. editor
# scenarios where opening a scene tab creates a new tree).
self._bound_tree: SceneTree | None = tree
# Drop any tap_key releases left over from a previous test/instance
# that crashed before its release frames advanced. The deque is
# class-level (so `SceneRunner.advance_frames` can drain it without
# holding a sim reference), which means stale entries would
# otherwise fire on the first frame this new instance drives.
type(self)._pending_releases.clear()
def _tree(self) -> SceneTree | None:
return self._bound_tree if self._bound_tree is not None else SceneTree.current()
# ------------------------------------------------------------------ keys
[docs]
def press_key(self, key: Key | int) -> None:
"""Simulate a key press. Accepts Key enum or int."""
Input._on_key(int(key), True)
tree = self._tree()
if tree is not None:
try:
key_enum = key if isinstance(key, Key) else Key(int(key))
except ValueError:
key_enum = None
if key_enum is not None:
tree.propagate_input(TreeInputEvent("key", key=key_enum, pressed=True))
[docs]
def release_key(self, key: Key | int) -> None:
"""Simulate a key release."""
Input._on_key(int(key), False)
tree = self._tree()
if tree is not None:
try:
key_enum = key if isinstance(key, Key) else Key(int(key))
except ValueError:
key_enum = None
if key_enum is not None:
tree.propagate_input(TreeInputEvent("key", key=key_enum, pressed=False))
[docs]
def tap_key(self, key: Key | int) -> None:
"""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`.
"""
self.press_key(key)
type(self)._pending_releases.append(int(key))
[docs]
@classmethod
def flush_pending_releases(cls) -> None:
"""Release every key queued by :meth:`tap_key` since the last flush.
Writes the release into the typed ``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 queue before iterating so a release handler that
re-queues a release does not extend the current drain.
"""
if not cls._pending_releases:
return
pending = list(cls._pending_releases)
cls._pending_releases.clear()
tree = SceneTree.current()
for key_int in pending:
Input._on_key(key_int, False)
if tree is not None:
try:
key_enum = Key(key_int)
except ValueError:
continue
tree.propagate_input(TreeInputEvent("key", key=key_enum, pressed=False))
# ------------------------------------------------------------------ mouse
[docs]
def press_mouse(self, button: MouseButton | int = MouseButton.LEFT,
position: tuple[float, float] | None = None) -> None:
"""Simulate mouse button press, optionally at a position."""
if position is not None:
self.move_mouse(position[0], position[1])
Input._on_mouse_button(int(button), True)
tree = self._tree()
if tree is not None:
mb = button if isinstance(button, MouseButton) else MouseButton(int(button))
tree.propagate_input(TreeInputEvent(
"mouse_button", mouse_button=mb, pressed=True,
position=Input._mouse_pos,
))
tree.ui_input(mouse_pos=Input._mouse_pos, button=mb, pressed=True)
[docs]
def release_mouse(self, button: MouseButton | int = MouseButton.LEFT) -> None:
"""Simulate mouse button release."""
Input._on_mouse_button(int(button), False)
tree = self._tree()
if tree is not None:
mb = button if isinstance(button, MouseButton) else MouseButton(int(button))
tree.propagate_input(TreeInputEvent(
"mouse_button", mouse_button=mb, pressed=False,
position=Input._mouse_pos,
))
tree.ui_input(mouse_pos=Input._mouse_pos, button=mb, pressed=False)
[docs]
def click(self, position: tuple[float, float],
button: MouseButton | int = MouseButton.LEFT) -> None:
"""Click at a screen position (press + release)."""
self.press_mouse(button, position)
self.release_mouse(button)
[docs]
def move_mouse(self, x: float, y: float) -> None:
"""Move the mouse cursor to (x, y)."""
old = Input._mouse_pos
Input._on_mouse_move(x, y)
tree = self._tree()
if tree is not None:
tree.propagate_input(TreeInputEvent(
"mouse_motion", position=(x, y),
delta=(x - old[0], y - old[1]),
))
tree.ui_input(mouse_pos=(x, y), button=None, pressed=False)
[docs]
def scroll(self, dx: float = 0.0, dy: float = -1.0) -> None:
"""Simulate scroll wheel. dy < 0 = scroll down, dy > 0 = scroll up."""
Input._scroll_delta = (dx, dy)
tree = self._tree()
if tree is not None:
tree.propagate_input(TreeInputEvent(
"scroll", position=Input._mouse_pos, delta=(dx, dy),
))
if dy > 0:
tree.ui_input(mouse_pos=Input._mouse_pos, key="scroll_up", pressed=True)
elif dy < 0:
tree.ui_input(mouse_pos=Input._mouse_pos, key="scroll_down", pressed=True)
# ------------------------------------------------------------------ touch
[docs]
def touch_down(self, finger_id: int = 0, position: tuple[float, float] = (0, 0),
pressure: float = 1.0) -> None:
"""Simulate a touch press (finger down)."""
Input._update_touch(finger_id, 0, position[0], position[1], pressure)
tree = self._tree()
if tree is not None:
tree.touch_input(finger_id, 0, position[0], position[1])
[docs]
def touch_move(self, finger_id: int = 0, position: tuple[float, float] = (0, 0),
pressure: float = 1.0) -> None:
"""Simulate a touch move (finger drag)."""
Input._update_touch(finger_id, 2, position[0], position[1], pressure)
tree = self._tree()
if tree is not None:
tree.touch_input(finger_id, 2, position[0], position[1])
[docs]
def touch_up(self, finger_id: int = 0, position: tuple[float, float] = (0, 0)) -> None:
"""Simulate a touch release (finger up)."""
Input._update_touch(finger_id, 1, position[0], position[1], 0.0)
tree = self._tree()
if tree is not None:
tree.touch_input(finger_id, 1, position[0], position[1])
# ------------------------------------------------------------------ misc
[docs]
def reset(self) -> None:
"""Reset all input state to defaults.
Also drains the class-level :attr:`_pending_releases` queue so a
``tap_key`` from an earlier test cannot fire a stale release the
first time the next test advances a frame.
"""
Input._reset()
type(self)._pending_releases.clear()