strest 0.1.10

Blazing-fast async HTTP load tester in Rust - lock-free design, real-time stats, distributed runs, and optional chart exports for high-load API testing.
Documentation
use std::time::Duration;

use crate::metrics::MetricRecord;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PlaybackState {
    pub(crate) start_ms: u64,
    pub(crate) cursor_ms: u64,
    pub(crate) end_ms: u64,
    pub(crate) playing: bool,
}

impl PlaybackState {
    #[must_use]
    pub(crate) const fn new(start_ms: u64, end_ms: u64) -> Self {
        Self {
            start_ms,
            cursor_ms: start_ms,
            end_ms,
            playing: true,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlaybackAction {
    TogglePlayPause,
    SeekBackward,
    SeekForward,
    SeekStart,
    SeekEnd,
    Restart,
}

#[must_use]
pub(crate) fn resolve_step_ms(step: Option<Duration>, default_step: Duration) -> u64 {
    let millis = step.unwrap_or(default_step).as_millis();
    u64::try_from(millis).unwrap_or(u64::MAX).max(1)
}

pub(crate) fn apply_playback_action(
    state: &mut PlaybackState,
    action: PlaybackAction,
    step_ms: u64,
) -> bool {
    let previous = *state;
    let step_ms = step_ms.max(1);
    match action {
        PlaybackAction::TogglePlayPause => {
            state.playing = !state.playing;
        }
        PlaybackAction::SeekBackward => {
            state.playing = false;
            state.cursor_ms = state.cursor_ms.saturating_sub(step_ms).max(state.start_ms);
        }
        PlaybackAction::SeekForward => {
            state.playing = false;
            state.cursor_ms = state.cursor_ms.saturating_add(step_ms).min(state.end_ms);
        }
        PlaybackAction::SeekStart | PlaybackAction::Restart => {
            state.playing = false;
            state.cursor_ms = state.start_ms;
        }
        PlaybackAction::SeekEnd => {
            state.playing = false;
            state.cursor_ms = state.end_ms;
        }
    }
    *state != previous
}

pub(crate) fn advance_playback(state: &mut PlaybackState, elapsed: Duration, tick_ms: u64) -> bool {
    if !state.playing || tick_ms == 0 {
        return false;
    }
    if elapsed < Duration::from_millis(tick_ms) {
        return false;
    }

    let tick_ms = u128::from(tick_ms);
    let steps = elapsed.as_millis().checked_div(tick_ms).unwrap_or(0);
    if steps == 0 {
        return false;
    }
    let advance_ms = u64::try_from(steps.saturating_mul(tick_ms)).unwrap_or(u64::MAX);
    state.cursor_ms = state.cursor_ms.saturating_add(advance_ms).min(state.end_ms);
    if state.cursor_ms >= state.end_ms {
        state.cursor_ms = state.end_ms;
        state.playing = false;
    }
    true
}

#[must_use]
pub(crate) fn records_range(records: &[MetricRecord]) -> Option<(u64, u64)> {
    let min = records.first().map(|record| record.elapsed_ms)?;
    let max = records.last().map(|record| record.elapsed_ms)?;
    Some((min, max))
}

#[must_use]
pub(crate) fn clamp_window_to_records(
    base: &PlaybackState,
    records_min: u64,
    records_max: u64,
) -> PlaybackState {
    let (min_ms, max_ms) = if records_min <= records_max {
        (records_min, records_max)
    } else {
        (records_max, records_min)
    };
    PlaybackState {
        start_ms: base.start_ms.max(min_ms).min(max_ms),
        cursor_ms: base.cursor_ms.clamp(min_ms, max_ms),
        end_ms: max_ms,
        playing: base.playing,
    }
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use crate::metrics::MetricRecord;

    use super::{
        PlaybackAction, PlaybackState, advance_playback, apply_playback_action,
        clamp_window_to_records, records_range, resolve_step_ms,
    };

    fn metric_record(elapsed_ms: u64) -> MetricRecord {
        MetricRecord {
            elapsed_ms,
            latency_ms: 1,
            status_code: 200,
            timed_out: false,
            transport_error: false,
            response_bytes: 0,
            in_flight_ops: 0,
        }
    }

    #[test]
    fn resolve_step_ms_defaults_and_clamps_zero() {
        assert_eq!(resolve_step_ms(None, Duration::from_secs(1)), 1000);
        assert_eq!(
            resolve_step_ms(Some(Duration::from_millis(0)), Duration::from_secs(1)),
            1
        );
    }

    #[test]
    fn apply_playback_action_handles_seek_and_restart() {
        let mut state = PlaybackState {
            start_ms: 1000,
            cursor_ms: 5000,
            end_ms: 9000,
            playing: true,
        };

        let changed_back = apply_playback_action(&mut state, PlaybackAction::SeekBackward, 2500);
        assert!(changed_back);
        assert_eq!(state.cursor_ms, 2500);
        assert!(!state.playing);

        let changed_forward =
            apply_playback_action(&mut state, PlaybackAction::SeekForward, 10_000);
        assert!(changed_forward);
        assert_eq!(state.cursor_ms, 9000);

        let changed_restart = apply_playback_action(&mut state, PlaybackAction::Restart, 2500);
        assert!(changed_restart);
        assert_eq!(state.cursor_ms, 1000);
    }

    #[test]
    fn advance_playback_moves_cursor_and_stops_at_end() {
        let mut state = PlaybackState {
            start_ms: 0,
            cursor_ms: 1000,
            end_ms: 2500,
            playing: true,
        };

        let changed_short_tick = advance_playback(&mut state, Duration::from_millis(500), 1000);
        assert!(!changed_short_tick);
        assert_eq!(state.cursor_ms, 1000);
        assert!(state.playing);

        let changed_full_tick = advance_playback(&mut state, Duration::from_millis(2100), 1000);
        assert!(changed_full_tick);
        assert_eq!(state.cursor_ms, 2500);
        assert!(!state.playing);
    }

    #[test]
    fn records_range_returns_min_and_max_when_present() {
        let records = vec![metric_record(100), metric_record(500)];
        assert_eq!(records_range(&records), Some((100, 500)));
        assert_eq!(records_range(&[]), None);
    }

    #[test]
    fn clamp_window_to_records_limits_state() {
        let base = PlaybackState {
            start_ms: 0,
            cursor_ms: 15_000,
            end_ms: 20_000,
            playing: true,
        };
        let clamped = clamp_window_to_records(&base, 1000, 10_000);
        assert_eq!(clamped.start_ms, 1000);
        assert_eq!(clamped.cursor_ms, 10_000);
        assert_eq!(clamped.end_ms, 10_000);
        assert!(clamped.playing);
    }
}