Source code for simvx.core.testing.recorder

"""TestRecorder -- records user input and generates replayable test scripts."""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass
from pathlib import Path

from ..input import Input, Key, key_to_name
from ..node import Node

log = logging.getLogger(__name__)

__all__ = ["RecordedEvent", "TestRecorder"]

# Threshold for grouping rapid keypresses into type_text() calls
_TYPE_GROUP_THRESHOLD = 0.2  # seconds

# Minimum distance squared for significant mouse movement
_MOVE_THRESHOLD_SQ = 25.0  # 5px


[docs] @dataclass class RecordedEvent: """A single recorded input event.""" timestamp: float # seconds since recording started event_type: str # "click", "key_press", "key_release", "mouse_move", "text", "scroll" x: float = 0.0 y: float = 0.0 button: int = 0 key: str = "" char: str = "" target_path: str = "" # widget path if identifiable
[docs] class TestRecorder(Node): """Records user input events and generates replayable test scripts. Attach to a scene to record interactions. Call stop_recording() to get Python source code that replays the session using UITestHarness, DemoRunner steps, or raw InputSimulator API. Toggle with F9 or programmatically via start/stop_recording(). """ format: str = "harness" # "harness", "demo_steps", or "raw_input" def __init__(self, format: str = "harness", **kwargs): super().__init__(**kwargs) self.name = self.name or "TestRecorder" self.format = format self._recording = False self._events: list[RecordedEvent] = [] self._start_time = 0.0 self._last_mouse_pos: tuple[float, float] = (0.0, 0.0) # Store original Input methods for unhooking self._orig_on_key = None self._orig_on_mouse_button = None self._orig_on_mouse_move = None # ------------------------------------------------------------------ public API
[docs] def start_recording(self) -> None: """Start capturing input events.""" if self._recording: return self._recording = True self._events.clear() self._start_time = time.monotonic() self._last_mouse_pos = Input._mouse_pos self._hook_input()
[docs] def stop_recording(self) -> str: """Stop recording and return generated test code in the current format.""" self._recording = False self._unhook_input() return self._generate(self.format)
[docs] def save_recording(self, path: Path, format: str | None = None) -> None: """Save recorded script to a file.""" code = self._generate(format or self.format) Path(path).write_text(code, encoding="utf-8")
@property def is_recording(self) -> bool: return self._recording @property def event_count(self) -> int: return len(self._events)
[docs] def record_event(self, event: RecordedEvent) -> None: """Manually inject a recorded event (for testing the recorder itself).""" self._events.append(event)
# ------------------------------------------------------------------ lifecycle
[docs] def process(self, dt: float) -> None: if Input.is_key_just_pressed(Key.F9): if self._recording: self.stop_recording() else: self.start_recording()
# ------------------------------------------------------------------ input hooking def _hook_input(self) -> None: """Wrap Input class methods to intercept all events.""" self._orig_on_key = Input._on_key.__func__ self._orig_on_mouse_button = Input._on_mouse_button.__func__ self._orig_on_mouse_move = Input._on_mouse_move.__func__ recorder = self @classmethod def hooked_on_key(cls, key: int, pressed: bool): recorder._orig_on_key(cls, key, pressed) if recorder._recording: recorder._on_key_event(key, pressed) @classmethod def hooked_on_mouse_button(cls, button: int, pressed: bool): recorder._orig_on_mouse_button(cls, button, pressed) if recorder._recording: recorder._on_mouse_button_event(button, pressed) @classmethod def hooked_on_mouse_move(cls, x: float, y: float): recorder._orig_on_mouse_move(cls, x, y) if recorder._recording: recorder._on_mouse_move_event(x, y) Input._on_key = hooked_on_key Input._on_mouse_button = hooked_on_mouse_button Input._on_mouse_move = hooked_on_mouse_move def _unhook_input(self) -> None: """Restore original Input class methods.""" if self._orig_on_key is not None: Input._on_key = classmethod(self._orig_on_key) self._orig_on_key = None if self._orig_on_mouse_button is not None: Input._on_mouse_button = classmethod(self._orig_on_mouse_button) self._orig_on_mouse_button = None if self._orig_on_mouse_move is not None: Input._on_mouse_move = classmethod(self._orig_on_mouse_move) self._orig_on_mouse_move = None # ------------------------------------------------------------------ event capture def _elapsed(self) -> float: return time.monotonic() - self._start_time def _on_key_event(self, key: int, pressed: bool) -> None: ts = self._elapsed() # Resolve printable character char = "" try: _k = Key(key) # Printable ASCII range (space through tilde) -- single char keys if Key.SPACE.value <= key <= 126: char = chr(key) if key != Key.SPACE.value else " " # Lowercase letters if Key.A.value <= key <= Key.Z.value: char = chr(key + 32) except ValueError: pass key_name = key_to_name(Key(key)) if key in Key._value2member_map_ else f"key_{key}" event_type = "key_press" if pressed else "key_release" self._events.append( RecordedEvent( timestamp=ts, event_type=event_type, key=key_name, char=char, x=Input._mouse_pos[0], y=Input._mouse_pos[1], ) ) def _on_mouse_button_event(self, button: int, pressed: bool) -> None: ts = self._elapsed() mx, my = Input._mouse_pos target = self._identify_target(mx, my) if pressed: self._events.append( RecordedEvent( timestamp=ts, event_type="click", x=mx, y=my, button=button, target_path=target, ) ) # We record both press and release for raw format; click event covers press def _on_mouse_move_event(self, x: float, y: float) -> None: # Collapse redundant moves: only record if distance is significant dx = x - self._last_mouse_pos[0] dy = y - self._last_mouse_pos[1] if dx * dx + dy * dy < _MOVE_THRESHOLD_SQ: return self._last_mouse_pos = (x, y) ts = self._elapsed() self._events.append( RecordedEvent( timestamp=ts, event_type="mouse_move", x=x, y=y, ) ) # ------------------------------------------------------------------ target identification def _identify_target(self, x: float, y: float) -> str: """Walk the scene tree to find the deepest Control containing (x, y).""" root = self._tree.root if self._tree else None if root is None: return "" return _find_deepest_control(root, x, y) # ------------------------------------------------------------------ code generation def _generate(self, fmt: str) -> str: if fmt == "harness": return self._gen_harness() elif fmt == "demo_steps": return self._gen_demo_steps() elif fmt == "raw_input": return self._gen_raw_input() raise ValueError(f"Unknown format: {fmt!r}. Use 'harness', 'demo_steps', or 'raw_input'.") def _gen_harness(self) -> str: lines = ["# Recorded by TestRecorder"] for group in self._group_events(): if group["type"] == "text": lines.append(f"harness.type_text({group['text']!r})") elif group["type"] == "click": evt = group["event"] comment = f" # {evt.target_path}" if evt.target_path else "" lines.append(f"harness.click(({int(evt.x)}, {int(evt.y)})){comment}") lines.append("harness.tick()") elif group["type"] == "key_press": evt = group["event"] lines.append(f"harness.press_key({evt.key!r})") lines.append("harness.tick()") elif group["type"] == "scroll": evt = group["event"] lines.append(f"harness.scroll(({int(evt.x)}, {int(evt.y)}))") return "\n".join(lines) + "\n" def _gen_demo_steps(self) -> str: lines = [ "# Recorded by TestRecorder", "from simvx.core.scripted_demo import Click, TypeText, PressKey, Wait, MoveTo", "from simvx.core.input import Key", "steps = [", ] groups = self._group_events() prev_time = 0.0 for group in groups: # Insert Wait steps for gaps > 0.3s gap = group["timestamp"] - prev_time if gap > 0.3: lines.append(f" Wait({gap:.1f}),") prev_time = group.get("end_time", group["timestamp"]) if group["type"] == "text": lines.append(f" TypeText({group['text']!r}, delay_per_char=0.05),") elif group["type"] == "click": evt = group["event"] comment = f" # {evt.target_path}" if evt.target_path else "" lines.append(f" Click({int(evt.x)}, {int(evt.y)}),{comment}") elif group["type"] == "key_press": evt = group["event"] lines.append(f" PressKey(Key.{evt.key.upper()}),") lines.append("]") return "\n".join(lines) + "\n" def _gen_raw_input(self) -> str: lines = [ "# Recorded by TestRecorder", "from simvx.core.testing.input_sim import InputSimulator", "from simvx.core.input import Key", "sim = InputSimulator()", ] for evt in self._events: if evt.event_type == "click": comment = f" # {evt.target_path}" if evt.target_path else "" lines.append(f"sim.click(({int(evt.x)}, {int(evt.y)})){comment}") elif evt.event_type == "key_press": lines.append(f"sim.tap_key(Key.{evt.key.upper()})") elif evt.event_type == "mouse_move": lines.append(f"sim.move_mouse({int(evt.x)}, {int(evt.y)})") return "\n".join(lines) + "\n" # ------------------------------------------------------------------ event grouping def _group_events(self) -> list[dict]: """Group events: consecutive printable key_presses within threshold become type_text.""" groups: list[dict] = [] i = 0 events = self._events while i < len(events): evt = events[i] if evt.event_type == "key_press" and evt.char and len(evt.char) == 1 and evt.char.isprintable(): # Start a text group chars = [evt.char] start_time = evt.timestamp end_time = evt.timestamp j = i + 1 while j < len(events): nxt = events[j] # Skip key_release events within the group if nxt.event_type == "key_release": j += 1 continue if ( nxt.event_type == "key_press" and nxt.char and len(nxt.char) == 1 and nxt.char.isprintable() and nxt.timestamp - end_time <= _TYPE_GROUP_THRESHOLD ): chars.append(nxt.char) end_time = nxt.timestamp j += 1 else: break if len(chars) >= 2: groups.append( { "type": "text", "text": "".join(chars), "timestamp": start_time, "end_time": end_time, } ) i = j continue # Single char -- fall through to key_press if evt.event_type == "click": groups.append({"type": "click", "event": evt, "timestamp": evt.timestamp}) elif evt.event_type == "key_press": groups.append({"type": "key_press", "event": evt, "timestamp": evt.timestamp}) elif evt.event_type == "scroll": groups.append({"type": "scroll", "event": evt, "timestamp": evt.timestamp}) i += 1 return groups
# ============================================================================ # Internal helpers for TestRecorder # ============================================================================ def _find_deepest_control(node: Node, x: float, y: float) -> str: """Walk the tree to find the deepest Control whose global rect contains (x, y).""" from ..ui.core import Control best: Control | None = None best_path: str = "" def walk(n: Node, path: str) -> None: nonlocal best, best_path if isinstance(n, Control): rx, ry, rw, rh = n.get_global_rect() if rx <= x < rx + rw and ry <= y < ry + rh: best = n best_path = path for child in n.children: child_path = f"{path}/{child.name}" if path else child.name walk(child, child_path) walk(node, node.name) return best_path