#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
use chromiumoxide::Page;
use chromiumoxide::cdp::browser_protocol::input::{DispatchKeyEventParams, DispatchKeyEventType};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::time::sleep;
use tracing::warn;
use crate::error::{BrowserError, Result};
const fn splitmix64(state: &mut u64) -> u64 {
*state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn rand_f64(state: &mut u64) -> f64 {
(splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64
}
fn rand_range(state: &mut u64, min: f64, max: f64) -> f64 {
rand_f64(state).mul_add(max - min, min)
}
fn rand_normal(state: &mut u64, mean: f64, std_dev: f64) -> f64 {
let u1 = rand_f64(state).max(1e-10);
let u2 = rand_f64(state);
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
std_dev.mul_add(z, mean)
}
fn lerp(p0: (f64, f64), p1: (f64, f64), t: f64) -> (f64, f64) {
(t.mul_add(p1.0 - p0.0, p0.0), t.mul_add(p1.1 - p0.1, p0.1))
}
fn cubic_bezier(
p0: (f64, f64),
p1: (f64, f64),
p2: (f64, f64),
p3: (f64, f64),
t: f64,
) -> (f64, f64) {
let a = lerp(p0, p1, t);
let b = lerp(p1, p2, t);
let c = lerp(p2, p3, t);
lerp(lerp(a, b, t), lerp(b, c, t), t)
}
pub struct MouseSimulator {
current_x: f64,
current_y: f64,
rng: u64,
}
impl Default for MouseSimulator {
fn default() -> Self {
Self::new()
}
}
impl MouseSimulator {
pub fn new() -> Self {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0x1234_5678_9abc_def0);
Self {
current_x: 0.0,
current_y: 0.0,
rng: seed,
}
}
pub const fn with_seed_and_position(seed: u64, x: f64, y: f64) -> Self {
Self {
current_x: x,
current_y: y,
rng: seed,
}
}
pub const fn position(&self) -> (f64, f64) {
(self.current_x, self.current_y)
}
pub fn compute_path(
&mut self,
from_x: f64,
from_y: f64,
to_x: f64,
to_y: f64,
) -> Vec<(f64, f64)> {
let dx = to_x - from_x;
let dy = to_y - from_y;
let distance = dx.hypot(dy);
let steps = ((distance / 8.0).round() as usize).clamp(12, 120);
let (px, py) = if distance > 1.0 {
(-dy / distance, dx / distance)
} else {
(1.0, 0.0)
};
let offset_scale = (distance * 0.35).min(200.0);
let cp1_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.5);
let cp2_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.4);
let cp1 = (
px.mul_add(cp1_off, from_x + dx / 3.0),
py.mul_add(cp1_off, from_y + dy / 3.0),
);
let cp2 = (
px.mul_add(cp2_off, from_x + 2.0 * dx / 3.0),
py.mul_add(cp2_off, from_y + 2.0 * dy / 3.0),
);
let p0 = (from_x, from_y);
let p3 = (to_x, to_y);
(0..=steps)
.map(|i| {
let t = i as f64 / steps as f64;
let (bx, by) = cubic_bezier(p0, cp1, cp2, p3, t);
let jx = rand_normal(&mut self.rng, 0.0, 0.4);
let jy = rand_normal(&mut self.rng, 0.0, 0.4);
(bx + jx, by + jy)
})
.collect()
}
pub async fn move_to(&mut self, page: &Page, to_x: f64, to_y: f64) -> Result<()> {
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType,
};
let path = self.compute_path(self.current_x, self.current_y, to_x, to_y);
for &(x, y) in &path {
let params = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(x)
.y(y)
.build()
.map_err(BrowserError::ConfigError)?;
page.execute(params)
.await
.map_err(|e| BrowserError::CdpError {
operation: "Input.dispatchMouseEvent(mouseMoved)".to_string(),
message: e.to_string(),
})?;
let delay_ms = rand_range(&mut self.rng, 10.0, 50.0) as u64;
sleep(Duration::from_millis(delay_ms)).await;
}
self.current_x = to_x;
self.current_y = to_y;
Ok(())
}
pub async fn click(&mut self, page: &Page, x: f64, y: f64) -> Result<()> {
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
self.move_to(page, x, y).await?;
let pre_ms = rand_range(&mut self.rng, 20.0, 80.0) as u64;
sleep(Duration::from_millis(pre_ms)).await;
let press = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1i64)
.build()
.map_err(BrowserError::ConfigError)?;
page.execute(press)
.await
.map_err(|e| BrowserError::CdpError {
operation: "Input.dispatchMouseEvent(mousePressed)".to_string(),
message: e.to_string(),
})?;
let hold_ms = rand_range(&mut self.rng, 50.0, 150.0) as u64;
sleep(Duration::from_millis(hold_ms)).await;
let release = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(x)
.y(y)
.button(MouseButton::Left)
.click_count(1i64)
.build()
.map_err(BrowserError::ConfigError)?;
page.execute(release)
.await
.map_err(|e| BrowserError::CdpError {
operation: "Input.dispatchMouseEvent(mouseReleased)".to_string(),
message: e.to_string(),
})?;
Ok(())
}
}
fn adjacent_key(ch: char, rng: &mut u64) -> char {
const ROWS: [&str; 3] = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
let lc = ch.to_lowercase().next().unwrap_or(ch);
for row in ROWS {
let chars: Vec<char> = row.chars().collect();
if let Some(idx) = chars.iter().position(|&c| c == lc) {
let adj = if idx == 0 {
chars.get(1).copied().unwrap_or(lc)
} else if idx == chars.len() - 1 || rand_f64(rng) < 0.5 {
chars.get(idx - 1).copied().unwrap_or(lc)
} else {
chars.get(idx + 1).copied().unwrap_or(lc)
};
return if ch.is_uppercase() {
adj.to_uppercase().next().unwrap_or(adj)
} else {
adj
};
}
}
'x'
}
pub struct TypingSimulator {
rng: u64,
error_rate: f64,
}
impl Default for TypingSimulator {
fn default() -> Self {
Self::new()
}
}
impl TypingSimulator {
pub fn new() -> Self {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0xdead_beef_cafe_babe);
Self {
rng: seed,
error_rate: 0.015,
}
}
pub const fn with_seed(seed: u64) -> Self {
Self {
rng: seed,
error_rate: 0.015,
}
}
#[must_use]
pub const fn with_error_rate(mut self, rate: f64) -> Self {
self.error_rate = rate.clamp(0.0, 1.0);
self
}
pub fn keystroke_delay(&mut self) -> Duration {
let ms = rand_normal(&mut self.rng, 80.0, 25.0).clamp(30.0, 200.0) as u64;
Duration::from_millis(ms)
}
async fn dispatch_key(
page: &Page,
kind: DispatchKeyEventType,
key: &str,
text: Option<&str>,
modifiers: i64,
) -> Result<()> {
let mut b = DispatchKeyEventParams::builder().r#type(kind).key(key);
if let Some(t) = text {
b = b.text(t);
}
if modifiers != 0 {
b = b.modifiers(modifiers);
}
let params = b.build().map_err(BrowserError::ConfigError)?;
page.execute(params)
.await
.map_err(|e| BrowserError::CdpError {
operation: "Input.dispatchKeyEvent".to_string(),
message: e.to_string(),
})?;
Ok(())
}
async fn type_backspace(page: &Page) -> Result<()> {
Self::dispatch_key(page, DispatchKeyEventType::RawKeyDown, "Backspace", None, 0).await?;
Self::dispatch_key(page, DispatchKeyEventType::KeyUp, "Backspace", None, 0).await?;
Ok(())
}
async fn type_char(page: &Page, ch: char) -> Result<()> {
let text = ch.to_string();
let modifiers: i64 = if ch.is_uppercase() && ch.is_alphabetic() {
8
} else {
0
};
let key = text.as_str();
Self::dispatch_key(
page,
DispatchKeyEventType::KeyDown,
key,
Some(&text),
modifiers,
)
.await?;
Self::dispatch_key(
page,
DispatchKeyEventType::Char,
key,
Some(&text),
modifiers,
)
.await?;
Self::dispatch_key(page, DispatchKeyEventType::KeyUp, key, None, modifiers).await?;
Ok(())
}
pub async fn type_text(&mut self, page: &Page, text: &str) -> Result<()> {
for ch in text.chars() {
if rand_f64(&mut self.rng) < self.error_rate {
let wrong = adjacent_key(ch, &mut self.rng);
Self::type_char(page, wrong).await?;
let typo_delay = rand_normal(&mut self.rng, 120.0, 30.0).clamp(60.0, 250.0) as u64;
sleep(Duration::from_millis(typo_delay)).await;
Self::type_backspace(page).await?;
let fix_delay = rand_range(&mut self.rng, 40.0, 120.0) as u64;
sleep(Duration::from_millis(fix_delay)).await;
}
Self::type_char(page, ch).await?;
sleep(self.keystroke_delay()).await;
if ch == ' ' || ch == '\n' {
let word_pause = rand_range(&mut self.rng, 100.0, 400.0) as u64;
sleep(Duration::from_millis(word_pause)).await;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InteractionLevel {
#[default]
None,
Low,
Medium,
High,
}
pub struct InteractionSimulator {
rng: u64,
mouse: MouseSimulator,
level: InteractionLevel,
}
impl Default for InteractionSimulator {
fn default() -> Self {
Self::new(InteractionLevel::None)
}
}
impl InteractionSimulator {
pub fn new(level: InteractionLevel) -> Self {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0x0123_4567_89ab_cdef);
Self {
rng: seed,
mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
level,
}
}
pub const fn with_seed(seed: u64, level: InteractionLevel) -> Self {
Self {
rng: seed,
mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
level,
}
}
async fn js(page: &Page, expr: String) -> Result<()> {
page.evaluate(expr)
.await
.map_err(|e| BrowserError::CdpError {
operation: "Runtime.evaluate".to_string(),
message: e.to_string(),
})?;
Ok(())
}
async fn scroll(page: &Page, delta_y: i64) -> Result<()> {
Self::js(
page,
format!("window.scrollBy({{top:{delta_y},behavior:'smooth'}})"),
)
.await
}
async fn do_keyactivity(&mut self, page: &Page) -> Result<()> {
const KEYS: &[&str] = &["ArrowDown", "Tab", "ArrowRight", "ArrowUp"];
let count = 3 + rand_range(&mut self.rng, 0.0, 4.0) as u32;
let mut successful_pairs = 0u32;
for i in 0..count {
let key = KEYS
.get((i as usize) % KEYS.len())
.copied()
.unwrap_or("Tab");
let down_delay = rand_range(&mut self.rng, 50.0, 120.0) as u64;
sleep(Duration::from_millis(down_delay)).await;
let keydown_ok = if let Err(e) = Self::js(
page,
format!(
"window.dispatchEvent(new KeyboardEvent('keydown',\
{{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
),
)
.await
{
warn!(key, "Failed to dispatch keydown event: {e}");
false
} else {
true
};
let hold_ms = rand_range(&mut self.rng, 20.0, 60.0) as u64;
sleep(Duration::from_millis(hold_ms)).await;
let keyup_ok = if let Err(e) = Self::js(
page,
format!(
"window.dispatchEvent(new KeyboardEvent('keyup',\
{{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
),
)
.await
{
warn!(key, "Failed to dispatch keyup event: {e}");
false
} else {
true
};
if keydown_ok && keyup_ok {
successful_pairs += 1;
}
}
if successful_pairs == 0 {
return Err(BrowserError::CdpError {
operation: "InteractionSimulator::do_keyactivity".to_string(),
message: "all synthetic key event dispatches failed".to_string(),
});
}
Ok(())
}
async fn do_scroll(&mut self, page: &Page) -> Result<()> {
let down = rand_range(&mut self.rng, 200.0, 600.0) as i64;
Self::scroll(page, down).await?;
let pause = rand_range(&mut self.rng, 300.0, 1_000.0) as u64;
sleep(Duration::from_millis(pause)).await;
let up = -(rand_range(&mut self.rng, 50.0, (down as f64) * 0.4) as i64);
Self::scroll(page, up).await?;
Ok(())
}
async fn do_mouse_wiggle(&mut self, page: &Page, vw: f64, vh: f64) -> Result<()> {
let tx = rand_range(&mut self.rng, vw * 0.1, vw * 0.9);
let ty = rand_range(&mut self.rng, vh * 0.1, vh * 0.9);
self.mouse.move_to(page, tx, ty).await
}
pub async fn random_interaction(
&mut self,
page: &Page,
viewport_w: f64,
viewport_h: f64,
) -> Result<()> {
match self.level {
InteractionLevel::None => {}
InteractionLevel::Low => {
self.do_scroll(page).await?;
let pause = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
sleep(Duration::from_millis(pause)).await;
}
InteractionLevel::Medium => {
self.do_scroll(page).await?;
let p1 = rand_range(&mut self.rng, 800.0, 2_000.0) as u64;
sleep(Duration::from_millis(p1)).await;
self.do_keyactivity(page).await?;
let p2 = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
sleep(Duration::from_millis(p2)).await;
self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
let p3 = rand_range(&mut self.rng, 400.0, 1_500.0) as u64;
sleep(Duration::from_millis(p3)).await;
}
InteractionLevel::High => {
self.do_scroll(page).await?;
let p1 = rand_range(&mut self.rng, 1_000.0, 5_000.0) as u64;
sleep(Duration::from_millis(p1)).await;
self.do_keyactivity(page).await?;
let p2 = rand_range(&mut self.rng, 400.0, 1_200.0) as u64;
sleep(Duration::from_millis(p2)).await;
self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
let p3 = rand_range(&mut self.rng, 800.0, 3_000.0) as u64;
sleep(Duration::from_millis(p3)).await;
self.do_keyactivity(page).await?;
let p4 = rand_range(&mut self.rng, 300.0, 800.0) as u64;
sleep(Duration::from_millis(p4)).await;
self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
let p5 = rand_range(&mut self.rng, 500.0, 2_000.0) as u64;
sleep(Duration::from_millis(p5)).await;
if rand_f64(&mut self.rng) < 0.4 {
let up = -(rand_range(&mut self.rng, 50.0, 200.0) as i64);
Self::scroll(page, up).await?;
sleep(Duration::from_millis(500)).await;
}
}
}
Ok(())
}
}
pub struct RequestPacer {
rng: u64,
mean_ms: u64,
std_ms: u64,
min_ms: u64,
max_ms: u64,
last_request: Option<Instant>,
}
impl Default for RequestPacer {
fn default() -> Self {
Self::new()
}
}
impl RequestPacer {
pub fn new() -> Self {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0xdead_beef_cafe_1337);
Self {
rng: seed,
mean_ms: 1_200,
std_ms: 400,
min_ms: 400,
max_ms: 4_000,
last_request: None,
}
}
pub fn with_timing(mean_ms: u64, std_ms: u64, min_ms: u64, max_ms: u64) -> Self {
let (min_ms, max_ms) = if min_ms <= max_ms {
(min_ms, max_ms)
} else {
(max_ms, min_ms)
};
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0xdead_beef_cafe_1337);
Self {
rng: seed,
mean_ms,
std_ms,
min_ms,
max_ms,
last_request: None,
}
}
pub fn with_rate(requests_per_second: f64) -> Self {
let mean_ms = (1_000.0 / requests_per_second.max(0.01)).max(1.0) as u64;
let std_ms = mean_ms / 4;
let min_ms = mean_ms / 2;
let max_ms = mean_ms.saturating_mul(2);
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() ^ u64::from(d.subsec_nanos()))
.unwrap_or(0xdead_beef_cafe_1337);
Self {
rng: seed,
mean_ms,
std_ms,
min_ms,
max_ms,
last_request: None,
}
}
pub async fn throttle(&mut self) {
let target_ms = rand_normal(&mut self.rng, self.mean_ms as f64, self.std_ms as f64)
.max(self.min_ms as f64)
.min(self.max_ms as f64) as u64;
if let Some(last) = self.last_request {
let elapsed_ms = last.elapsed().as_millis() as u64;
if elapsed_ms < target_ms {
sleep(Duration::from_millis(target_ms - elapsed_ms)).await;
}
}
self.last_request = Some(Instant::now());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mouse_simulator_starts_at_origin() {
let mouse = MouseSimulator::new();
assert_eq!(mouse.position(), (0.0, 0.0));
}
#[test]
fn mouse_simulator_with_seed_and_position() {
let mouse = MouseSimulator::with_seed_and_position(42, 150.0, 300.0);
assert_eq!(mouse.position(), (150.0, 300.0));
}
#[test]
fn compute_path_minimum_steps_for_zero_distance() {
let mut mouse = MouseSimulator::with_seed_and_position(1, 100.0, 100.0);
let path = mouse.compute_path(100.0, 100.0, 100.0, 100.0);
assert!(path.len() >= 13);
}
#[test]
fn compute_path_scales_with_distance() {
let mut mouse_near = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
let mut mouse_far = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
let short_path = mouse_near.compute_path(0.0, 0.0, 30.0, 0.0);
let long_path = mouse_far.compute_path(0.0, 0.0, 800.0, 0.0);
assert!(long_path.len() > short_path.len());
}
#[test]
fn compute_path_step_cap_at_120() {
let mut mouse = MouseSimulator::with_seed_and_position(99, 0.0, 0.0);
let path = mouse.compute_path(0.0, 0.0, 10_000.0, 0.0);
assert!(path.len() <= 121);
}
#[test]
fn compute_path_endpoint_near_target() {
let mut mouse = MouseSimulator::with_seed_and_position(7, 0.0, 0.0);
let target_x = 500.0_f64;
let target_y = 300.0_f64;
let path = mouse.compute_path(0.0, 0.0, target_x, target_y);
let last = path.last().copied().unwrap_or_default();
assert!(
(last.0 - target_x).abs() < 5.0,
"x off by {}",
(last.0 - target_x).abs()
);
assert!(
(last.1 - target_y).abs() < 5.0,
"y off by {}",
(last.1 - target_y).abs()
);
}
#[test]
fn compute_path_startpoint_near_origin() {
let mut mouse = MouseSimulator::with_seed_and_position(3, 50.0, 80.0);
let path = mouse.compute_path(50.0, 80.0, 400.0, 200.0);
if let Some(first) = path.first() {
assert!((first.0 - 50.0).abs() < 5.0);
assert!((first.1 - 80.0).abs() < 5.0);
}
}
#[test]
fn compute_path_diagonal_movement() {
let mut mouse = MouseSimulator::with_seed_and_position(17, 0.0, 0.0);
let path = mouse.compute_path(0.0, 0.0, 300.0, 400.0);
assert!(path.len() >= 13);
let last = path.last().copied().unwrap_or_default();
assert!((last.0 - 300.0).abs() < 5.0);
assert!((last.1 - 400.0).abs() < 5.0);
}
#[test]
fn compute_path_deterministic_with_same_seed() {
let mut m1 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
let mut m2 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
let path1 = m1.compute_path(0.0, 0.0, 200.0, 150.0);
let path2 = m2.compute_path(0.0, 0.0, 200.0, 150.0);
assert_eq!(path1.len(), path2.len());
for (a, b) in path1.iter().zip(path2.iter()) {
assert!((a.0 - b.0).abs() < 1e-9);
assert!((a.1 - b.1).abs() < 1e-9);
}
}
#[test]
fn cubic_bezier_at_t0_is_p0() {
let p0 = (10.0, 20.0);
let p1 = (50.0, 100.0);
let p2 = (150.0, 80.0);
let p3 = (200.0, 30.0);
let result = cubic_bezier(p0, p1, p2, p3, 0.0);
assert!((result.0 - p0.0).abs() < 1e-9);
assert!((result.1 - p0.1).abs() < 1e-9);
}
#[test]
fn cubic_bezier_at_t1_is_p3() {
let p0 = (10.0, 20.0);
let p1 = (50.0, 100.0);
let p2 = (150.0, 80.0);
let p3 = (200.0, 30.0);
let result = cubic_bezier(p0, p1, p2, p3, 1.0);
assert!((result.0 - p3.0).abs() < 1e-9);
assert!((result.1 - p3.1).abs() < 1e-9);
}
#[test]
fn rand_f64_is_in_unit_interval() {
let mut state = 12345u64;
for _ in 0..1000 {
let v = rand_f64(&mut state);
assert!((0.0..1.0).contains(&v), "out of range: {v}");
}
}
#[test]
fn rand_range_stays_in_bounds() {
let mut state = 99999u64;
for _ in 0..1000 {
let v = rand_range(&mut state, 10.0, 50.0);
assert!((10.0..50.0).contains(&v), "out of range: {v}");
}
}
#[test]
fn typing_simulator_keystroke_delay_is_positive() {
let mut ts = TypingSimulator::new();
assert!(ts.keystroke_delay().as_millis() > 0);
}
#[test]
fn typing_simulator_keystroke_delay_in_range() {
let mut ts = TypingSimulator::with_seed(123);
for _ in 0..50 {
let d = ts.keystroke_delay();
assert!(
d.as_millis() >= 30 && d.as_millis() <= 200,
"delay out of range: {}ms",
d.as_millis()
);
}
}
#[test]
fn typing_simulator_error_rate_clamps_to_one() {
let ts = TypingSimulator::new().with_error_rate(2.0);
assert!(
(ts.error_rate - 1.0).abs() < 1e-9,
"rate should clamp to 1.0"
);
}
#[test]
fn typing_simulator_error_rate_clamps_to_zero() {
let ts = TypingSimulator::new().with_error_rate(-0.5);
assert!(ts.error_rate.abs() < 1e-9, "rate should clamp to 0.0");
}
#[test]
fn typing_simulator_deterministic_with_same_seed() {
let mut t1 = TypingSimulator::with_seed(999);
let mut t2 = TypingSimulator::with_seed(999);
assert_eq!(t1.keystroke_delay(), t2.keystroke_delay());
}
#[test]
fn adjacent_key_returns_different_char() {
let mut rng = 42u64;
for &ch in &['a', 'b', 's', 'k', 'z', 'm'] {
let adj = adjacent_key(ch, &mut rng);
assert_ne!(adj, ch, "adjacent_key({ch}) should not return itself");
}
}
#[test]
fn adjacent_key_preserves_case() {
let mut rng = 7u64;
let adj = adjacent_key('A', &mut rng);
assert!(
adj.is_uppercase(),
"adjacent_key('A') should return uppercase"
);
}
#[test]
fn adjacent_key_non_alpha_returns_fallback() {
let mut rng = 1u64;
assert_eq!(adjacent_key('!', &mut rng), 'x');
assert_eq!(adjacent_key('5', &mut rng), 'x');
}
#[test]
fn interaction_level_default_is_none() {
assert_eq!(InteractionLevel::default(), InteractionLevel::None);
}
#[test]
fn interaction_simulator_with_seed_is_deterministic() {
let s1 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
let s2 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
assert_eq!(s1.rng, s2.rng);
}
#[test]
fn interaction_simulator_default_is_none_level() {
let sim = InteractionSimulator::default();
assert_eq!(sim.level, InteractionLevel::None);
}
#[test]
fn request_pacer_new_has_expected_defaults() {
let p = RequestPacer::new();
assert_eq!(p.mean_ms, 1_200);
assert_eq!(p.min_ms, 400);
assert_eq!(p.max_ms, 4_000);
assert!(p.last_request.is_none());
}
#[test]
fn request_pacer_with_timing_stores_params() {
let p = RequestPacer::with_timing(500, 100, 200, 2_000);
assert_eq!(p.mean_ms, 500);
assert_eq!(p.std_ms, 100);
assert_eq!(p.min_ms, 200);
assert_eq!(p.max_ms, 2_000);
}
#[test]
fn request_pacer_with_rate_computes_mean() {
let p = RequestPacer::with_rate(0.5);
assert_eq!(p.mean_ms, 2_000);
assert_eq!(p.min_ms, 1_000);
assert_eq!(p.max_ms, 4_000);
}
#[test]
fn request_pacer_with_rate_clamps_extreme() {
let p = RequestPacer::with_rate(10_000.0);
assert!(p.mean_ms >= 1);
}
#[test]
fn request_pacer_with_timing_swaps_inverted_bounds() {
let p = RequestPacer::with_timing(500, 100, 2_000, 200);
assert_eq!(p.min_ms, 200);
assert_eq!(p.max_ms, 2_000);
}
#[tokio::test]
async fn request_pacer_throttle_first_immediate_then_waits() {
let mut p = RequestPacer::with_timing(25, 0, 25, 25);
p.throttle().await;
let started = Instant::now();
p.throttle().await;
assert!(
started.elapsed() >= Duration::from_millis(15),
"second throttle should wait before returning"
);
}
}