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()