"""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