#![forbid(unsafe_code)]
use ftui_core::event::Event;
use web_time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct TimedEvent {
pub event: Event,
pub delay: Duration,
}
impl TimedEvent {
pub fn new(event: Event, delay: Duration) -> Self {
Self { event, delay }
}
pub fn immediate(event: Event) -> Self {
Self {
event,
delay: Duration::ZERO,
}
}
}
#[derive(Debug, Clone)]
pub struct MacroMetadata {
pub name: String,
pub terminal_size: (u16, u16),
pub total_duration: Duration,
}
#[derive(Debug, Clone)]
pub struct InputMacro {
events: Vec<TimedEvent>,
metadata: MacroMetadata,
}
impl InputMacro {
pub fn new(events: Vec<TimedEvent>, metadata: MacroMetadata) -> Self {
Self { events, metadata }
}
pub fn from_events(name: impl Into<String>, events: Vec<Event>) -> Self {
let timed: Vec<TimedEvent> = events.into_iter().map(TimedEvent::immediate).collect();
Self {
metadata: MacroMetadata {
name: name.into(),
terminal_size: (80, 24),
total_duration: Duration::ZERO,
},
events: timed,
}
}
pub fn events(&self) -> &[TimedEvent] {
&self.events
}
#[inline]
pub fn metadata(&self) -> &MacroMetadata {
&self.metadata
}
#[inline]
pub fn len(&self) -> usize {
self.events.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
#[inline]
pub fn total_duration(&self) -> Duration {
self.metadata.total_duration
}
pub fn bare_events(&self) -> Vec<Event> {
self.events.iter().map(|te| te.event.clone()).collect()
}
pub fn replay_with_timing<M: crate::program::Model>(
&self,
sim: &mut crate::simulator::ProgramSimulator<M>,
) {
let mut player = MacroPlayer::new(self);
player.replay_with_timing(sim);
}
pub fn replay_with_sleeper<M, F>(
&self,
sim: &mut crate::simulator::ProgramSimulator<M>,
sleep: F,
) where
M: crate::program::Model,
F: FnMut(Duration),
{
let mut player = MacroPlayer::new(self);
player.replay_with_sleeper(sim, sleep);
}
}
pub struct MacroRecorder {
name: String,
terminal_size: (u16, u16),
events: Vec<TimedEvent>,
last_event_time: Instant,
recorded_duration: Duration,
}
impl MacroRecorder {
pub fn new(name: impl Into<String>) -> Self {
let now = Instant::now();
Self {
name: name.into(),
terminal_size: (80, 24),
events: Vec::new(),
last_event_time: now,
recorded_duration: Duration::ZERO,
}
}
#[must_use]
pub fn with_terminal_size(mut self, width: u16, height: u16) -> Self {
self.terminal_size = (width, height);
self
}
pub fn record_event(&mut self, event: Event) {
let now = Instant::now();
let delay = now.saturating_duration_since(self.last_event_time);
#[cfg(feature = "tracing")]
tracing::debug!(event = ?event, delay = ?delay, "macro record event");
self.events.push(TimedEvent::new(event, delay));
self.recorded_duration = self.recorded_duration.saturating_add(delay);
self.last_event_time = now;
}
pub fn record_event_with_delay(&mut self, event: Event, delay: Duration) {
#[cfg(feature = "tracing")]
tracing::debug!(event = ?event, delay = ?delay, "macro record event");
self.events.push(TimedEvent::new(event, delay));
self.recorded_duration = self.recorded_duration.saturating_add(delay);
self.last_event_time = self
.last_event_time
.checked_add(delay)
.unwrap_or_else(Instant::now);
}
pub fn event_count(&self) -> usize {
self.events.len()
}
pub fn finish(self) -> InputMacro {
InputMacro {
events: self.events,
metadata: MacroMetadata {
name: self.name,
terminal_size: self.terminal_size,
total_duration: self.recorded_duration,
},
}
}
}
pub struct MacroPlayer<'a> {
input_macro: &'a InputMacro,
position: usize,
elapsed: Duration,
}
impl<'a> MacroPlayer<'a> {
pub fn new(input_macro: &'a InputMacro) -> Self {
Self {
input_macro,
position: 0,
elapsed: Duration::ZERO,
}
}
pub fn position(&self) -> usize {
self.position
}
pub fn elapsed(&self) -> Duration {
self.elapsed
}
pub fn is_done(&self) -> bool {
self.position >= self.input_macro.len()
}
pub fn remaining(&self) -> usize {
self.input_macro.len().saturating_sub(self.position)
}
pub fn step<M: crate::program::Model>(
&mut self,
sim: &mut crate::simulator::ProgramSimulator<M>,
) -> bool {
if self.is_done() {
return false;
}
let timed = &self.input_macro.events[self.position];
#[cfg(feature = "tracing")]
tracing::debug!(event = ?timed.event, delay = ?timed.delay, "macro playback event");
self.elapsed = self.elapsed.saturating_add(timed.delay);
sim.inject_events(std::slice::from_ref(&timed.event));
self.position += 1;
true
}
pub fn replay_all<M: crate::program::Model>(
&mut self,
sim: &mut crate::simulator::ProgramSimulator<M>,
) {
while !self.is_done() && sim.is_running() {
self.step(sim);
}
}
pub fn replay_with_timing<M: crate::program::Model>(
&mut self,
sim: &mut crate::simulator::ProgramSimulator<M>,
) {
self.replay_with_sleeper(sim, std::thread::sleep);
}
pub fn replay_with_sleeper<M, F>(
&mut self,
sim: &mut crate::simulator::ProgramSimulator<M>,
mut sleep: F,
) where
M: crate::program::Model,
F: FnMut(Duration),
{
while !self.is_done() && sim.is_running() {
let timed = &self.input_macro.events[self.position];
if timed.delay > Duration::ZERO {
sleep(timed.delay);
}
self.step(sim);
}
}
pub fn replay_until<M: crate::program::Model>(
&mut self,
sim: &mut crate::simulator::ProgramSimulator<M>,
until: Duration,
) {
while !self.is_done() && sim.is_running() {
let timed = &self.input_macro.events[self.position];
let next_elapsed = self.elapsed.saturating_add(timed.delay);
if next_elapsed > until {
break;
}
self.step(sim);
}
}
pub fn reset(&mut self) {
self.position = 0;
self.elapsed = Duration::ZERO;
}
}
#[derive(Debug, Clone)]
pub struct MacroPlayback {
input_macro: InputMacro,
position: usize,
elapsed: Duration,
next_due: Duration,
speed: f64,
looping: bool,
start_logged: bool,
stop_logged: bool,
error_logged: bool,
}
const MAX_DUE_EVENTS_PER_ADVANCE: usize = 4096;
impl MacroPlayback {
pub fn new(input_macro: InputMacro) -> Self {
let next_due = input_macro
.events()
.first()
.map(|e| e.delay)
.unwrap_or(Duration::ZERO);
Self {
input_macro,
position: 0,
elapsed: Duration::ZERO,
next_due,
speed: 1.0,
looping: false,
start_logged: false,
stop_logged: false,
error_logged: false,
}
}
pub fn set_speed(&mut self, speed: f64) {
self.speed = normalize_speed(speed);
}
#[must_use]
pub fn with_speed(mut self, speed: f64) -> Self {
self.set_speed(speed);
self
}
pub fn set_looping(&mut self, looping: bool) {
self.looping = looping;
}
#[must_use]
pub fn with_looping(mut self, looping: bool) -> Self {
self.set_looping(looping);
self
}
pub fn speed(&self) -> f64 {
self.speed
}
pub fn position(&self) -> usize {
self.position
}
pub fn elapsed(&self) -> Duration {
self.elapsed
}
pub fn is_done(&self) -> bool {
if self.input_macro.is_empty() {
return true;
}
if self.looping && self.input_macro.total_duration() > Duration::ZERO {
return false;
}
self.position >= self.input_macro.len()
}
pub fn reset(&mut self) {
self.position = 0;
self.elapsed = Duration::ZERO;
self.next_due = self
.input_macro
.events()
.first()
.map(|e| e.delay)
.unwrap_or(Duration::ZERO);
self.start_logged = false;
self.stop_logged = false;
self.error_logged = false;
}
pub fn advance(&mut self, delta: Duration) -> Vec<Event> {
if self.input_macro.is_empty() {
#[cfg(feature = "tracing")]
if !self.error_logged {
let meta = self.input_macro.metadata();
tracing::warn!(
macro_event = "playback_error",
reason = "macro_empty",
name = %meta.name,
events = 0usize,
duration_ms = duration_millis_saturating(self.input_macro.total_duration()),
);
self.error_logged = true;
}
return Vec::new();
}
if self.is_done() {
return Vec::new();
}
#[cfg(feature = "tracing")]
if !self.start_logged {
let meta = self.input_macro.metadata();
tracing::info!(
macro_event = "playback_start",
name = %meta.name,
events = self.input_macro.len(),
duration_ms = duration_millis_saturating(self.input_macro.total_duration()),
speed = self.speed,
looping = self.looping,
);
self.start_logged = true;
}
let scaled = scale_duration(delta, self.speed);
let total_duration = self.input_macro.total_duration();
if self.looping && total_duration > Duration::ZERO && scaled == Duration::MAX {
self.elapsed =
loop_elapsed_remainder(self.elapsed, total_duration).saturating_add(total_duration);
} else {
self.elapsed = self.elapsed.saturating_add(scaled);
}
let events = self.drain_due_events();
#[cfg(feature = "tracing")]
if self.is_done() && !self.stop_logged {
let meta = self.input_macro.metadata();
tracing::info!(
macro_event = "playback_stop",
reason = "completed",
name = %meta.name,
events = self.input_macro.len(),
elapsed_ms = duration_millis_saturating(self.elapsed),
looping = self.looping,
);
self.stop_logged = true;
}
events
}
fn drain_due_events(&mut self) -> Vec<Event> {
let mut out = Vec::new();
let total_duration = self.input_macro.total_duration();
let can_loop = self.looping && total_duration > Duration::ZERO;
if can_loop && self.position >= self.input_macro.len() {
self.elapsed = loop_elapsed_remainder(self.elapsed, total_duration);
self.position = 0;
self.next_due = self
.input_macro
.events()
.first()
.map(|e| e.delay)
.unwrap_or(Duration::ZERO);
}
while out.len() < MAX_DUE_EVENTS_PER_ADVANCE
&& self.position < self.input_macro.len()
&& self.elapsed >= self.next_due
{
let timed = &self.input_macro.events[self.position];
#[cfg(feature = "tracing")]
tracing::debug!(event = ?timed.event, delay = ?timed.delay, "macro playback event");
out.push(timed.event.clone());
self.position += 1;
if self.position < self.input_macro.len() {
self.next_due = self
.next_due
.saturating_add(self.input_macro.events[self.position].delay);
} else if can_loop {
self.elapsed = self.elapsed.saturating_sub(total_duration);
self.position = 0;
self.next_due = self
.input_macro
.events()
.first()
.map(|e| e.delay)
.unwrap_or(Duration::ZERO);
}
}
if can_loop && out.len() == MAX_DUE_EVENTS_PER_ADVANCE {
self.elapsed = loop_elapsed_remainder(self.elapsed, total_duration);
if self.position >= self.input_macro.len() {
self.position = 0;
self.next_due = self
.input_macro
.events()
.first()
.map(|e| e.delay)
.unwrap_or(Duration::ZERO);
}
}
out
}
}
fn normalize_speed(speed: f64) -> f64 {
if !speed.is_finite() {
return 1.0;
}
if speed <= 0.0 {
return 0.0;
}
speed
}
fn scale_duration(delta: Duration, speed: f64) -> Duration {
if delta == Duration::ZERO {
return Duration::ZERO;
}
let speed = normalize_speed(speed);
if speed == 0.0 {
return Duration::ZERO;
}
if speed == 1.0 {
return delta;
}
duration_from_secs_f64_saturating(delta.as_secs_f64() * speed)
}
fn duration_from_secs_f64_saturating(secs: f64) -> Duration {
if secs.is_nan() || secs <= 0.0 {
return Duration::ZERO;
}
Duration::try_from_secs_f64(secs).unwrap_or(Duration::MAX)
}
#[cfg(any(feature = "tracing", test))]
fn duration_millis_saturating(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn loop_elapsed_remainder(elapsed: Duration, total_duration: Duration) -> Duration {
let total_secs = total_duration.as_secs_f64();
if total_secs <= 0.0 {
return Duration::ZERO;
}
let elapsed_secs = elapsed.as_secs_f64() % total_secs;
duration_from_secs_f64_saturating(elapsed_secs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordingState {
Idle,
Recording,
Paused,
}
pub struct EventRecorder {
inner: MacroRecorder,
state: RecordingState,
pause_start: Option<Instant>,
total_paused: Duration,
event_count: usize,
}
impl EventRecorder {
pub fn new(name: impl Into<String>) -> Self {
Self {
inner: MacroRecorder::new(name),
state: RecordingState::Idle,
pause_start: None,
total_paused: Duration::ZERO,
event_count: 0,
}
}
#[must_use]
pub fn with_terminal_size(mut self, width: u16, height: u16) -> Self {
self.inner = self.inner.with_terminal_size(width, height);
self
}
pub fn state(&self) -> RecordingState {
self.state
}
pub fn is_recording(&self) -> bool {
self.state == RecordingState::Recording
}
pub fn start(&mut self) {
match self.state {
RecordingState::Idle => {
self.state = RecordingState::Recording;
#[cfg(feature = "tracing")]
tracing::info!(
macro_event = "recorder_start",
name = %self.inner.name,
term_cols = self.inner.terminal_size.0,
term_rows = self.inner.terminal_size.1,
);
}
RecordingState::Paused => {
self.resume();
}
RecordingState::Recording => {} }
}
pub fn pause(&mut self) {
if self.state == RecordingState::Recording {
self.state = RecordingState::Paused;
self.pause_start = Some(Instant::now());
}
}
pub fn resume(&mut self) {
if self.state == RecordingState::Paused {
if let Some(pause_start) = self.pause_start.take() {
self.total_paused = self.total_paused.saturating_add(pause_start.elapsed());
}
self.inner.last_event_time = Instant::now();
self.state = RecordingState::Recording;
}
}
pub fn record(&mut self, event: &Event) -> bool {
if self.state != RecordingState::Recording {
return false;
}
self.inner.record_event(event.clone());
self.event_count += 1;
true
}
pub fn record_with_delay(&mut self, event: &Event, delay: Duration) -> bool {
if self.state != RecordingState::Recording {
return false;
}
self.inner.record_event_with_delay(event.clone(), delay);
self.event_count += 1;
true
}
pub fn event_count(&self) -> usize {
self.event_count
}
pub fn total_paused(&self) -> Duration {
let mut total = self.total_paused;
if let Some(pause_start) = self.pause_start {
total = total.saturating_add(pause_start.elapsed());
}
total
}
pub fn finish(self) -> InputMacro {
self.finish_internal(true)
}
#[allow(unused_variables)]
fn finish_internal(self, log: bool) -> InputMacro {
let paused = self.total_paused();
let macro_data = self.inner.finish();
#[cfg(feature = "tracing")]
if log {
let meta = macro_data.metadata();
tracing::info!(
macro_event = "recorder_stop",
name = %meta.name,
events = macro_data.len(),
duration_ms = duration_millis_saturating(macro_data.total_duration()),
paused_ms = duration_millis_saturating(paused),
term_cols = meta.terminal_size.0,
term_rows = meta.terminal_size.1,
);
}
macro_data
}
pub fn discard(self) -> usize {
self.event_count
}
}
#[derive(Debug, Clone)]
pub struct RecordingFilter {
pub keys: bool,
pub mouse: bool,
pub resize: bool,
pub paste: bool,
pub ime: bool,
pub focus: bool,
}
impl Default for RecordingFilter {
fn default() -> Self {
Self {
keys: true,
mouse: true,
resize: true,
paste: true,
ime: true,
focus: true,
}
}
}
impl RecordingFilter {
pub fn keys_only() -> Self {
Self {
keys: true,
mouse: false,
resize: false,
paste: false,
ime: false,
focus: false,
}
}
pub fn accepts(&self, event: &Event) -> bool {
match event {
Event::Key(_) => self.keys,
Event::Mouse(_) => self.mouse,
Event::Resize { .. } => self.resize,
Event::Paste(_) => self.paste,
Event::Ime(_) => self.ime,
Event::Focus(_) => self.focus,
Event::Clipboard(_) => true, Event::Tick => false, }
}
}
pub struct FilteredEventRecorder {
recorder: EventRecorder,
filter: RecordingFilter,
filtered_count: usize,
}
impl FilteredEventRecorder {
pub fn new(name: impl Into<String>, filter: RecordingFilter) -> Self {
Self {
recorder: EventRecorder::new(name),
filter,
filtered_count: 0,
}
}
#[must_use]
pub fn with_terminal_size(mut self, width: u16, height: u16) -> Self {
self.recorder = self.recorder.with_terminal_size(width, height);
self
}
pub fn start(&mut self) {
self.recorder.start();
}
pub fn pause(&mut self) {
self.recorder.pause();
}
pub fn resume(&mut self) {
self.recorder.resume();
}
pub fn state(&self) -> RecordingState {
self.recorder.state()
}
pub fn is_recording(&self) -> bool {
self.recorder.is_recording()
}
pub fn record(&mut self, event: &Event) -> bool {
if !self.filter.accepts(event) {
self.filtered_count += 1;
return false;
}
self.recorder.record(event)
}
pub fn filtered_count(&self) -> usize {
self.filtered_count
}
pub fn event_count(&self) -> usize {
self.recorder.event_count()
}
#[allow(unused_variables)]
pub fn finish(self) -> InputMacro {
let filtered = self.filtered_count;
let paused = self.recorder.total_paused();
let macro_data = self.recorder.finish_internal(false);
#[cfg(feature = "tracing")]
{
let meta = macro_data.metadata();
tracing::info!(
macro_event = "recorder_stop",
name = %meta.name,
events = macro_data.len(),
filtered,
duration_ms = duration_millis_saturating(macro_data.total_duration()),
paused_ms = duration_millis_saturating(paused),
term_cols = meta.terminal_size.0,
term_rows = meta.terminal_size.1,
);
}
macro_data
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::program::{Cmd, Model};
use crate::simulator::ProgramSimulator;
use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
use ftui_render::frame::Frame;
use proptest::prelude::*;
struct Counter {
value: i32,
}
#[derive(Debug)]
enum CounterMsg {
Increment,
Decrement,
Quit,
}
impl From<Event> for CounterMsg {
fn from(event: Event) -> Self {
match event {
Event::Key(k) if k.code == KeyCode::Char('+') => CounterMsg::Increment,
Event::Key(k) if k.code == KeyCode::Char('-') => CounterMsg::Decrement,
Event::Key(k) if k.code == KeyCode::Char('q') => CounterMsg::Quit,
_ => CounterMsg::Increment,
}
}
}
impl Model for Counter {
type Message = CounterMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
match msg {
CounterMsg::Increment => {
self.value += 1;
Cmd::none()
}
CounterMsg::Decrement => {
self.value -= 1;
Cmd::none()
}
CounterMsg::Quit => Cmd::quit(),
}
}
fn view(&self, _frame: &mut Frame) {}
}
fn key_event(c: char) -> Event {
Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: Modifiers::empty(),
kind: KeyEventKind::Press,
})
}
#[test]
fn timed_event_immediate_has_zero_delay() {
let te = TimedEvent::immediate(key_event('a'));
assert_eq!(te.delay, Duration::ZERO);
}
#[test]
fn timed_event_new_preserves_delay() {
let delay = Duration::from_millis(100);
let te = TimedEvent::new(key_event('x'), delay);
assert_eq!(te.delay, delay);
}
#[test]
fn macro_from_events_has_zero_delays() {
let m = InputMacro::from_events("test", vec![key_event('+'), key_event('-')]);
assert_eq!(m.len(), 2);
assert!(!m.is_empty());
assert_eq!(m.total_duration(), Duration::ZERO);
for te in m.events() {
assert_eq!(te.delay, Duration::ZERO);
}
}
#[test]
fn macro_metadata() {
let m = InputMacro::from_events("my_macro", vec![key_event('a')]);
assert_eq!(m.metadata().name, "my_macro");
assert_eq!(m.metadata().terminal_size, (80, 24));
}
#[test]
fn empty_macro() {
let m = InputMacro::from_events("empty", vec![]);
assert!(m.is_empty());
assert_eq!(m.len(), 0);
}
#[test]
fn bare_events_extracts_events() {
let events = vec![key_event('+'), key_event('-'), key_event('q')];
let m = InputMacro::from_events("test", events.clone());
let bare = m.bare_events();
assert_eq!(bare.len(), 3);
assert_eq!(bare, events);
}
#[test]
fn recorder_captures_events() {
let mut rec = MacroRecorder::new("rec_test");
rec.record_event(key_event('+'));
rec.record_event(key_event('+'));
rec.record_event(key_event('-'));
assert_eq!(rec.event_count(), 3);
let m = rec.finish();
assert_eq!(m.len(), 3);
assert_eq!(m.metadata().name, "rec_test");
}
#[test]
fn recorder_with_terminal_size() {
let rec = MacroRecorder::new("sized").with_terminal_size(120, 40);
let m = rec.finish();
assert_eq!(m.metadata().terminal_size, (120, 40));
}
#[test]
fn recorder_explicit_delays() {
let mut rec = MacroRecorder::new("delayed");
rec.record_event_with_delay(key_event('+'), Duration::from_millis(0));
rec.record_event_with_delay(key_event('-'), Duration::from_millis(50));
rec.record_event_with_delay(key_event('q'), Duration::from_millis(100));
let m = rec.finish();
assert_eq!(m.events()[0].delay, Duration::from_millis(0));
assert_eq!(m.events()[1].delay, Duration::from_millis(50));
assert_eq!(m.events()[2].delay, Duration::from_millis(100));
assert_eq!(m.total_duration(), Duration::from_millis(150));
}
#[test]
fn recorder_explicit_delay_overflow_saturates_total_duration() {
let mut rec = MacroRecorder::new("huge-delay");
rec.record_event_with_delay(key_event('+'), Duration::MAX);
rec.record_event_with_delay(key_event('-'), Duration::from_millis(1));
let m = rec.finish();
assert_eq!(m.events()[0].delay, Duration::MAX);
assert_eq!(m.events()[1].delay, Duration::from_millis(1));
assert_eq!(m.total_duration(), Duration::MAX);
assert_eq!(duration_millis_saturating(m.total_duration()), u64::MAX);
}
#[test]
fn player_replays_all_events() {
let m = InputMacro::from_events(
"replay",
vec![key_event('+'), key_event('+'), key_event('+')],
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
assert_eq!(player.remaining(), 3);
assert!(!player.is_done());
player.replay_all(&mut sim);
assert!(player.is_done());
assert_eq!(player.remaining(), 0);
assert_eq!(sim.model().value, 3);
}
#[test]
fn player_step_advances_position() {
let m = InputMacro::from_events("step", vec![key_event('+'), key_event('+')]);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
assert_eq!(player.position(), 0);
assert!(player.step(&mut sim));
assert_eq!(player.position(), 1);
assert_eq!(sim.model().value, 1);
assert!(player.step(&mut sim));
assert_eq!(player.position(), 2);
assert_eq!(sim.model().value, 2);
assert!(!player.step(&mut sim));
}
#[test]
fn player_stops_on_quit() {
let m = InputMacro::from_events(
"quit_test",
vec![key_event('+'), key_event('q'), key_event('+')],
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
player.replay_all(&mut sim);
assert_eq!(sim.model().value, 1);
assert!(!sim.is_running());
}
#[test]
fn player_replay_until_respects_time() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
TimedEvent::new(key_event('+'), Duration::from_millis(20)),
TimedEvent::new(key_event('+'), Duration::from_millis(100)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "timed".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(130),
},
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
player.replay_until(&mut sim, Duration::from_millis(50));
assert_eq!(sim.model().value, 2);
assert_eq!(player.position(), 2);
player.replay_until(&mut sim, Duration::from_millis(200));
assert_eq!(sim.model().value, 3);
assert!(player.is_done());
}
#[test]
fn player_elapsed_tracks_virtual_time() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
TimedEvent::new(key_event('+'), Duration::from_millis(20)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "elapsed".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(30),
},
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
assert_eq!(player.elapsed(), Duration::ZERO);
player.step(&mut sim);
assert_eq!(player.elapsed(), Duration::from_millis(10));
player.step(&mut sim);
assert_eq!(player.elapsed(), Duration::from_millis(30));
}
#[test]
fn player_reset_restarts_playback() {
let m = InputMacro::from_events("reset", vec![key_event('+'), key_event('+')]);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
player.replay_all(&mut sim);
assert_eq!(sim.model().value, 2);
assert!(player.is_done());
player.reset();
assert_eq!(player.position(), 0);
assert!(!player.is_done());
let mut sim2 = ProgramSimulator::new(Counter { value: 10 });
sim2.init();
player.replay_all(&mut sim2);
assert_eq!(sim2.model().value, 12);
}
#[test]
fn player_replay_with_sleeper_respects_delays() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
TimedEvent::new(key_event('+'), Duration::from_millis(0)),
TimedEvent::new(key_event('+'), Duration::from_millis(25)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "timed_sleep".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(35),
},
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
let mut sleeps = Vec::new();
player.replay_with_sleeper(&mut sim, |d| sleeps.push(d));
assert_eq!(
sleeps,
vec![Duration::from_millis(10), Duration::from_millis(25)]
);
assert_eq!(sim.model().value, 3);
}
#[test]
fn playback_emits_due_events_in_order() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "playback".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(20),
},
);
let mut playback = MacroPlayback::new(m.clone());
assert!(playback.advance(Duration::from_millis(5)).is_empty());
let first = playback.advance(Duration::from_millis(5));
assert_eq!(first.len(), 1);
let second = playback.advance(Duration::from_millis(10));
assert_eq!(second.len(), 1);
assert!(playback.advance(Duration::from_millis(10)).is_empty());
}
#[test]
fn playback_speed_scales_time() {
let events = vec![TimedEvent::new(key_event('+'), Duration::from_millis(10))];
let m = InputMacro::new(
events,
MacroMetadata {
name: "speed".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(10),
},
);
let mut playback = MacroPlayback::new(m.clone()).with_speed(2.0);
let events = playback.advance(Duration::from_millis(5));
assert_eq!(events.len(), 1);
}
#[test]
fn playback_speed_huge_value_does_not_panic() {
let events = vec![TimedEvent::new(key_event('+'), Duration::from_millis(10))];
let m = InputMacro::new(
events,
MacroMetadata {
name: "huge-speed".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(10),
},
);
let mut playback = MacroPlayback::new(m).with_speed(f64::MAX);
let events = playback.advance(Duration::from_millis(1));
assert_eq!(events.len(), 1);
}
#[test]
fn playback_speed_huge_looping_multiple_advances_do_not_panic() {
let events = vec![TimedEvent::new(key_event('+'), Duration::from_millis(10))];
let m = InputMacro::new(
events,
MacroMetadata {
name: "huge-speed-looping".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(10),
},
);
let mut playback = MacroPlayback::new(m)
.with_speed(f64::MAX)
.with_looping(true);
let first = playback.advance(Duration::from_millis(1));
assert_eq!(first.len(), 1);
let second = playback.advance(Duration::from_millis(1));
assert_eq!(second.len(), 1);
}
#[test]
fn playback_looping_handles_large_delta() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "loop".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(20),
},
);
let mut playback = MacroPlayback::new(m.clone()).with_looping(true);
let events = playback.advance(Duration::from_millis(50));
assert_eq!(events.len(), 5);
}
#[test]
fn playback_zero_duration_does_not_loop_forever() {
let m = InputMacro::from_events("zero", vec![key_event('+'), key_event('+')]);
let mut playback = MacroPlayback::new(m.clone()).with_looping(true);
let events = playback.advance(Duration::ZERO);
assert_eq!(events.len(), 2);
assert!(playback.advance(Duration::from_millis(10)).is_empty());
}
#[test]
fn macro_replay_with_sleeper_wrapper() {
let events = vec![
TimedEvent::new(key_event('+'), Duration::from_millis(5)),
TimedEvent::new(key_event('+'), Duration::from_millis(10)),
];
let m = InputMacro::new(
events,
MacroMetadata {
name: "wrapper".to_string(),
terminal_size: (80, 24),
total_duration: Duration::from_millis(15),
},
);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut slept = Vec::new();
m.replay_with_sleeper(&mut sim, |d| slept.push(d));
assert_eq!(
slept,
vec![Duration::from_millis(5), Duration::from_millis(10)]
);
assert_eq!(sim.model().value, 2);
}
#[test]
fn empty_macro_replay() {
let m = InputMacro::from_events("empty", vec![]);
let mut sim = ProgramSimulator::new(Counter { value: 5 });
sim.init();
let mut player = MacroPlayer::new(&m);
assert!(player.is_done());
player.replay_all(&mut sim);
assert_eq!(sim.model().value, 5);
}
#[test]
fn macro_with_mixed_events() {
let events = vec![
key_event('+'),
Event::Resize {
width: 100,
height: 50,
},
key_event('-'),
Event::Focus(true),
key_event('+'),
];
let m = InputMacro::from_events("mixed", events);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
let mut player = MacroPlayer::new(&m);
player.replay_all(&mut sim);
assert_eq!(sim.model().value, 3);
}
#[test]
fn deterministic_replay() {
let m = InputMacro::from_events(
"determinism",
vec![
key_event('+'),
key_event('+'),
key_event('-'),
key_event('+'),
key_event('+'),
],
);
let result1 = {
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
MacroPlayer::new(&m).replay_all(&mut sim);
sim.model().value
};
let result2 = {
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
MacroPlayer::new(&m).replay_all(&mut sim);
sim.model().value
};
assert_eq!(result1, result2);
assert_eq!(result1, 3);
}
#[test]
fn event_recorder_starts_idle() {
let rec = EventRecorder::new("test");
assert_eq!(rec.state(), RecordingState::Idle);
assert!(!rec.is_recording());
assert_eq!(rec.event_count(), 0);
}
#[test]
fn event_recorder_start_activates() {
let mut rec = EventRecorder::new("test");
rec.start();
assert_eq!(rec.state(), RecordingState::Recording);
assert!(rec.is_recording());
}
#[test]
fn event_recorder_ignores_events_when_idle() {
let mut rec = EventRecorder::new("test");
assert!(!rec.record(&key_event('a')));
assert_eq!(rec.event_count(), 0);
}
#[test]
fn event_recorder_records_when_active() {
let mut rec = EventRecorder::new("test");
rec.start();
assert!(rec.record(&key_event('a')));
assert!(rec.record(&key_event('b')));
assert_eq!(rec.event_count(), 2);
let m = rec.finish();
assert_eq!(m.len(), 2);
}
#[test]
fn event_recorder_pause_ignores_events() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.record(&key_event('a'));
rec.pause();
assert_eq!(rec.state(), RecordingState::Paused);
assert!(!rec.is_recording());
assert!(!rec.record(&key_event('b')));
assert_eq!(rec.event_count(), 1);
}
#[test]
fn event_recorder_resume_after_pause() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.record(&key_event('a'));
rec.pause();
rec.record(&key_event('b')); rec.resume();
assert!(rec.is_recording());
rec.record(&key_event('c'));
assert_eq!(rec.event_count(), 2);
let m = rec.finish();
assert_eq!(m.len(), 2);
assert_eq!(m.bare_events()[0], key_event('a'));
assert_eq!(m.bare_events()[1], key_event('c'));
}
#[test]
fn event_recorder_resume_saturates_total_paused() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.total_paused = Duration::MAX;
rec.pause();
std::thread::sleep(Duration::from_millis(1));
rec.resume();
assert_eq!(rec.total_paused(), Duration::MAX);
}
#[test]
fn event_recorder_active_pause_saturates_total_paused_query() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.total_paused = Duration::MAX;
rec.pause();
std::thread::sleep(Duration::from_millis(1));
assert_eq!(rec.total_paused(), Duration::MAX);
}
#[test]
fn event_recorder_start_resumes_when_paused() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.pause();
assert_eq!(rec.state(), RecordingState::Paused);
rec.start(); assert_eq!(rec.state(), RecordingState::Recording);
}
#[test]
fn event_recorder_pause_noop_when_idle() {
let mut rec = EventRecorder::new("test");
rec.pause();
assert_eq!(rec.state(), RecordingState::Idle);
}
#[test]
fn event_recorder_resume_noop_when_idle() {
let mut rec = EventRecorder::new("test");
rec.resume();
assert_eq!(rec.state(), RecordingState::Idle);
}
#[test]
fn event_recorder_discard() {
let mut rec = EventRecorder::new("test");
rec.start();
rec.record(&key_event('a'));
rec.record(&key_event('b'));
let count = rec.discard();
assert_eq!(count, 2);
}
#[test]
fn event_recorder_with_terminal_size() {
let mut rec = EventRecorder::new("sized").with_terminal_size(120, 40);
rec.start();
rec.record(&key_event('x'));
let m = rec.finish();
assert_eq!(m.metadata().terminal_size, (120, 40));
}
#[test]
fn event_recorder_finish_produces_valid_macro() {
let mut rec = EventRecorder::new("full_test");
rec.start();
rec.record(&key_event('+'));
rec.record(&key_event('+'));
rec.record(&key_event('-'));
let m = rec.finish();
assert_eq!(m.len(), 3);
assert_eq!(m.metadata().name, "full_test");
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
MacroPlayer::new(&m).replay_all(&mut sim);
assert_eq!(sim.model().value, 1); }
#[test]
fn event_recorder_record_with_delay() {
let mut rec = EventRecorder::new("delayed");
rec.start();
assert!(rec.record_with_delay(&key_event('a'), Duration::from_millis(50)));
assert!(rec.record_with_delay(&key_event('b'), Duration::from_millis(100)));
assert_eq!(rec.event_count(), 2);
let m = rec.finish();
assert_eq!(m.events()[0].delay, Duration::from_millis(50));
assert_eq!(m.events()[1].delay, Duration::from_millis(100));
}
#[test]
fn event_recorder_record_with_delay_ignores_when_idle() {
let mut rec = EventRecorder::new("test");
assert!(!rec.record_with_delay(&key_event('a'), Duration::from_millis(50)));
assert_eq!(rec.event_count(), 0);
}
#[test]
fn filter_default_accepts_all() {
let filter = RecordingFilter::default();
assert!(filter.accepts(&key_event('a')));
assert!(filter.accepts(&Event::Resize {
width: 80,
height: 24
}));
assert!(filter.accepts(&Event::Focus(true)));
}
#[test]
fn filter_keys_only() {
let filter = RecordingFilter::keys_only();
assert!(filter.accepts(&key_event('a')));
assert!(!filter.accepts(&Event::Resize {
width: 80,
height: 24
}));
assert!(!filter.accepts(&Event::Focus(true)));
}
#[test]
fn filter_custom() {
let filter = RecordingFilter {
keys: true,
mouse: false,
resize: false,
paste: true,
ime: false,
focus: false,
};
assert!(filter.accepts(&key_event('a')));
assert!(!filter.accepts(&Event::Resize {
width: 80,
height: 24
}));
assert!(!filter.accepts(&Event::Focus(false)));
}
#[test]
fn filtered_recorder_records_matching_events() {
let mut rec = FilteredEventRecorder::new("filtered", RecordingFilter::default());
rec.start();
assert!(rec.record(&key_event('a')));
assert_eq!(rec.event_count(), 1);
assert_eq!(rec.filtered_count(), 0);
}
#[test]
fn filtered_recorder_skips_filtered_events() {
let mut rec = FilteredEventRecorder::new("keys_only", RecordingFilter::keys_only());
rec.start();
assert!(rec.record(&key_event('a')));
assert!(!rec.record(&Event::Focus(true)));
assert!(!rec.record(&Event::Resize {
width: 100,
height: 50
}));
assert!(rec.record(&key_event('b')));
assert_eq!(rec.event_count(), 2);
assert_eq!(rec.filtered_count(), 2);
}
#[test]
fn filtered_recorder_finish_produces_macro() {
let mut rec = FilteredEventRecorder::new("test", RecordingFilter::keys_only());
rec.start();
rec.record(&key_event('+'));
rec.record(&Event::Focus(true)); rec.record(&key_event('+'));
let m = rec.finish();
assert_eq!(m.len(), 2);
let mut sim = ProgramSimulator::new(Counter { value: 0 });
sim.init();
MacroPlayer::new(&m).replay_all(&mut sim);
assert_eq!(sim.model().value, 2);
}
#[test]
fn filtered_recorder_pause_resume() {
let mut rec = FilteredEventRecorder::new("test", RecordingFilter::default());
rec.start();
rec.record(&key_event('a'));
rec.pause();
assert!(!rec.record(&key_event('b'))); rec.resume();
rec.record(&key_event('c'));
assert_eq!(rec.event_count(), 2);
}
#[test]
fn filtered_recorder_with_terminal_size() {
let mut rec = FilteredEventRecorder::new("sized", RecordingFilter::default())
.with_terminal_size(200, 60);
rec.start();
rec.record(&key_event('x'));
let m = rec.finish();
assert_eq!(m.metadata().terminal_size, (200, 60));
}
#[derive(Default)]
struct EventSink {
events: Vec<Event>,
}
#[derive(Debug, Clone)]
struct EventMsg(Event);
impl From<Event> for EventMsg {
fn from(event: Event) -> Self {
Self(event)
}
}
impl Model for EventSink {
type Message = EventMsg;
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
self.events.push(msg.0);
Cmd::none()
}
fn view(&self, _frame: &mut Frame) {}
}
proptest! {
#[test]
fn recorder_with_explicit_delays_roundtrips(pairs in proptest::collection::vec((0u8..=25, 0u16..=2000), 0..32)) {
let mut recorder = MacroRecorder::new("prop").with_terminal_size(80, 24);
let mut expected_total = Duration::ZERO;
let mut expected_events = Vec::with_capacity(pairs.len());
for (ch_idx, delay_ms) in &pairs {
let ch = char::from(b'a' + *ch_idx);
let delay = Duration::from_millis(*delay_ms as u64);
expected_total += delay;
let ev = key_event(ch);
expected_events.push(ev.clone());
recorder.record_event_with_delay(ev, delay);
}
let m = recorder.finish();
prop_assert_eq!(m.len(), pairs.len());
prop_assert_eq!(m.metadata().terminal_size, (80, 24));
prop_assert_eq!(m.total_duration(), expected_total);
prop_assert_eq!(m.bare_events(), expected_events);
}
#[test]
fn player_replays_events_in_order(pairs in proptest::collection::vec((0u8..=25, 0u16..=2000), 0..32)) {
let mut timed = Vec::with_capacity(pairs.len());
let mut total = Duration::ZERO;
let mut expected_events = Vec::with_capacity(pairs.len());
for (ch_idx, delay_ms) in &pairs {
let ch = char::from(b'a' + *ch_idx);
let delay = Duration::from_millis(*delay_ms as u64);
total += delay;
let ev = key_event(ch);
expected_events.push(ev.clone());
timed.push(TimedEvent::new(ev, delay));
}
let m = InputMacro::new(timed, MacroMetadata {
name: "prop".to_string(),
terminal_size: (80, 24),
total_duration: total,
});
let mut sim = ProgramSimulator::new(EventSink::default());
sim.init();
let mut player = MacroPlayer::new(&m);
player.replay_all(&mut sim);
prop_assert_eq!(sim.model().events.clone(), expected_events);
prop_assert_eq!(player.elapsed(), total);
}
}
}