1use serde::{Deserialize, Serialize};
9
10pub mod behavior;
11pub mod browser_presets;
12pub mod gpu_presets;
13
14pub use behavior::*;
16pub use browser_presets::*;
17pub use gpu_presets::*;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct GpuProfile {
24 pub vendor: String,
26 pub renderer: String,
28 pub version: String,
30 pub shading_language_version: String,
32 pub unmasked_vendor: String,
34 pub unmasked_renderer: String,
36 pub extensions: Vec<String>,
38 pub params: Vec<(u32, serde_json::Value)>,
40 pub shader_precision: Vec<(u32, u32, [i32; 3])>,
42 #[serde(default)]
44 pub webgl1: Option<WebGL1Surface>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WebGL1Surface {
50 pub version: String,
51 pub shading_language_version: String,
52 pub extensions: Vec<String>,
53}
54
55impl Default for GpuProfile {
56 fn default() -> Self {
57 nvidia_rtx_3060_windows()
58 }
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
65pub enum DeviceClass {
66 #[default]
67 Desktop,
68 MobileAndroid,
69 MobileIOS,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MediaDeviceInfo {
77 pub device_id: String,
78 pub kind: String,
79 pub label: String,
80 pub group_id: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct StealthProfile {
91 pub user_agent: String,
93 pub browser_name: String,
94 pub browser_version: String,
95 pub os_name: String,
96 pub os_version: String,
97 pub platform: String,
98 pub vendor: String,
99 pub vendor_sub: String,
100 pub product_sub: String,
101 pub app_version: String,
102
103 pub screen_width: u32,
105 pub screen_height: u32,
106 pub screen_avail_width: u32,
107 pub screen_avail_height: u32,
108 pub screen_avail_top: u32,
109 pub screen_color_depth: u32,
110 pub device_pixel_ratio: f64,
111 pub cpu_cores: u8,
112 pub device_memory: u8,
113 pub max_touch_points: u8,
114
115 pub webgl_vendor: String,
117 pub webgl_renderer: String,
118 #[serde(default = "default_gpu_profile")]
119 pub gpu_profile: GpuProfile,
120
121 pub language: String,
123 pub languages: Vec<String>,
124 pub timezone: String,
125
126 #[serde(default = "default_cpu_architecture")]
128 pub cpu_architecture: String,
129 #[serde(default = "default_cpu_bitness")]
130 pub cpu_bitness: String,
131 #[serde(default)]
132 pub platform_version: String,
133 #[serde(default)]
134 pub ua_model: String,
135 #[serde(default)]
136 pub ua_wow64: bool,
137
138 #[serde(default)]
140 pub device_class: DeviceClass,
141 pub tls_impersonate: String,
142 pub connection_effective_type: String,
143 pub connection_rtt: u32,
144 pub connection_downlink: f64,
145
146 pub pdf_viewer_enabled: bool,
148 pub plugins_count: u32,
149 pub mime_types_count: u32,
150
151 pub canvas_seed: u64,
153 pub audio_seed: u64,
154 #[serde(default = "default_audio_sample_rate")]
155 pub audio_sample_rate: u32,
156
157 #[serde(default)]
159 pub has_platform_authenticator: bool,
160 #[serde(default = "default_true")]
161 pub conditional_mediation: bool,
162
163 #[serde(default)]
165 pub allow_http3: bool,
166
167 pub prefers_color_scheme: String,
169 pub pointer_type: String,
170 pub hover_capability: String,
171 #[serde(default = "default_color_gamut")]
172 pub color_gamut: String,
173
174 pub inner_width: u32,
176 pub inner_height: u32,
177 pub outer_width: u32,
178 pub outer_height: u32,
179
180 #[serde(default)]
182 pub proxy: Option<String>,
183
184 #[serde(default)]
186 pub media_devices: Vec<MediaDeviceInfo>,
187
188 #[serde(default = "default_true")]
190 pub enforce_csp: bool,
191}
192
193fn default_color_gamut() -> String {
194 "srgb".into()
195}
196fn default_true() -> bool {
197 true
198}
199fn default_gpu_profile() -> GpuProfile {
200 nvidia_rtx_3060_windows()
201}
202fn default_cpu_architecture() -> String {
203 "x86".into()
204}
205fn default_cpu_bitness() -> String {
206 "64".into()
207}
208fn default_audio_sample_rate() -> u32 {
209 44100
210}
211
212impl Default for StealthProfile {
213 fn default() -> Self {
214 chrome_148_windows()
215 }
216}
217
218impl StealthProfile {
221 pub fn validate(&self) -> Result<(), Vec<String>> {
223 let mut errors = Vec::new();
224
225 let ua_major = self.browser_version.split('.').next().unwrap_or("");
227 let chrome_form = format!("{ua_major}.0.0.0");
228 let firefox_form = format!("{ua_major}.0");
229 if !self.user_agent.contains(&chrome_form) && !self.user_agent.contains(&firefox_form) {
230 errors.push(format!(
231 "UA '{}' doesn't contain reduced major version '{}' or '{}'",
232 self.user_agent, chrome_form, firefox_form
233 ));
234 }
235
236 match self.os_name.as_str() {
238 "Windows" if self.platform != "Win32" => {
239 errors.push(format!("Windows OS but platform is '{}'", self.platform));
240 }
241 "macOS" if self.platform != "MacIntel" => {
242 errors.push(format!("macOS but platform is '{}'", self.platform));
243 }
244 "Linux" if !self.platform.starts_with("Linux") => {
245 errors.push(format!("Linux OS but platform is '{}'", self.platform));
246 }
247 _ => {}
248 }
249
250 if self.max_touch_points > 0 && self.screen_width > 1024 && self.pointer_type == "fine" {
252 errors.push("Touch points > 0 but desktop pointer type".into());
253 }
254
255 if self.webgl_renderer.contains("NVIDIA") && !self.webgl_vendor.contains("NVIDIA") {
257 errors.push("WebGL renderer is NVIDIA but vendor doesn't match".into());
258 }
259 if self.webgl_renderer.contains("Intel") && !self.webgl_vendor.contains("Intel") {
260 errors.push("WebGL renderer is Intel but vendor doesn't match".into());
261 }
262 if self.webgl_renderer.contains("Apple") && !self.webgl_vendor.contains("Apple") {
263 errors.push("WebGL renderer is Apple but vendor doesn't match".into());
264 }
265
266 if self.webgl_renderer.contains("Apple")
268 && !matches!(self.os_name.as_str(), "macOS" | "iOS")
269 {
270 errors.push("Apple GPU on non-Apple OS".into());
271 }
272
273 if self.screen_width == 0 || self.screen_height == 0 {
275 errors.push("Screen dimensions cannot be zero".into());
276 }
277 if self.inner_width > self.screen_width {
278 errors.push("inner_width > screen_width".into());
279 }
280 if self.outer_width < self.inner_width {
281 errors.push("outer_width < inner_width".into());
282 }
283
284 if self.cpu_cores == 0 || self.cpu_cores > 128 {
286 errors.push(format!("Unrealistic cpu_cores: {}", self.cpu_cores));
287 }
288 if self.device_memory == 0 && self.os_name != "iOS" {
289 errors.push(format!("Unrealistic device_memory: {}", self.device_memory));
290 }
291
292 if !self.languages.contains(&self.language) {
294 errors.push(format!(
295 "language '{}' not in languages {:?}",
296 self.language, self.languages
297 ));
298 }
299
300 if !matches!(self.cpu_architecture.as_str(), "x86" | "arm" | "") {
302 errors.push(format!(
303 "cpu_architecture must be 'x86', 'arm', or '' (got '{}')",
304 self.cpu_architecture
305 ));
306 }
307 if !matches!(self.cpu_bitness.as_str(), "64" | "32") {
308 errors.push(format!(
309 "cpu_bitness must be '64' or '32' (got '{}')",
310 self.cpu_bitness
311 ));
312 }
313 if self.ua_wow64 && (self.os_name != "Windows" || self.cpu_bitness != "32") {
314 errors.push(format!(
315 "ua_wow64=true requires os_name=Windows and cpu_bitness=32 (got {} / {})",
316 self.os_name, self.cpu_bitness
317 ));
318 }
319 if self.os_name == "Linux" && !self.platform_version.is_empty() {
320 errors.push(format!(
321 "Chrome on Linux must report empty platform_version (got '{}')",
322 self.platform_version
323 ));
324 }
325 if self.cpu_architecture == "arm"
326 && !matches!(
327 self.os_name.as_str(),
328 "macOS" | "Android" | "ChromeOS" | "iOS"
329 )
330 {
331 errors.push(format!(
332 "cpu_architecture=arm only on macOS/Android/ChromeOS/iOS (got '{}')",
333 self.os_name
334 ));
335 }
336 if !self.ua_model.is_empty() && self.max_touch_points == 0 {
337 errors.push(format!(
338 "ua_model='{}' on a desktop (max_touch_points=0) profile",
339 self.ua_model
340 ));
341 }
342 if !matches!(self.audio_sample_rate, 44100 | 48000 | 96000 | 192000) {
343 errors.push(format!(
344 "audio_sample_rate must be one of {{44100, 48000, 96000, 192000}} (got {})",
345 self.audio_sample_rate
346 ));
347 }
348
349 if errors.is_empty() {
350 Ok(())
351 } else {
352 Err(errors)
353 }
354 }
355}
356
357pub fn with_locale(
361 mut base: StealthProfile,
362 language: &str,
363 languages: &[&str],
364 timezone: &str,
365) -> StealthProfile {
366 base.language = language.into();
367 base.languages = languages.iter().map(|s| (*s).to_string()).collect();
368 base.timezone = timezone.into();
369 base
370}
371
372pub fn random_desktop() -> StealthProfile {
374 use rand::RngExt;
375 let mut rng = rand::rng();
376 let mut profile = match rng.random_range(0..3u32) {
377 0 => chrome_148_windows(),
378 1 => chrome_148_macos(),
379 _ => chrome_148_linux(),
380 };
381 profile.canvas_seed = rng.random();
382 profile.audio_seed = rng.random();
383 profile
384}
385
386pub fn chrome_148_macos_sampled() -> StealthProfile {
392 chrome_148_macos_sampled_with_rng(&mut rand::rng())
393}
394
395pub fn chrome_148_macos_sampled_with_rng(rng: &mut impl rand::RngExt) -> StealthProfile {
397 let mut p = chrome_148_macos();
398
399 type ChipConfig = (
400 &'static [u8],
401 &'static [u8],
402 &'static [(u32, u32, u32)],
403 GpuProfile,
404 );
405 let chip_idx = rng.random_range(0..3u32);
406 let (cores_pool, ram_pool, screens, gpu): ChipConfig = match chip_idx {
407 0 => (
408 &[8],
409 &[8, 16, 24],
410 &[(1512, 982, 949), (1728, 1117, 1010)],
411 apple_m3_macos(),
412 ),
413 1 => (
414 &[11, 12],
415 &[18, 36],
416 &[(1800, 1169, 1100), (2056, 1329, 1253)],
417 apple_m3_pro_macos(),
418 ),
419 _ => (
420 &[14, 16],
421 &[36, 48],
422 &[(1800, 1169, 1100), (2056, 1329, 1253)],
423 apple_m3_max_macos(),
424 ),
425 };
426
427 p.cpu_cores = cores_pool[rng.random_range(0..cores_pool.len())];
428 p.device_memory = ram_pool[rng.random_range(0..ram_pool.len())];
429
430 let (w, h, ah) = screens[rng.random_range(0..screens.len())];
431 p.screen_width = w;
432 p.screen_height = h;
433 p.screen_avail_width = w;
434 p.screen_avail_height = ah;
435 p.inner_width = w;
436 p.inner_height = h.saturating_sub(111);
437 p.outer_width = w;
438 p.outer_height = h;
439
440 p.gpu_profile = gpu;
441 p.webgl_renderer = p.gpu_profile.unmasked_renderer.clone();
442
443 p.canvas_seed = rng.random();
444 p.audio_seed = rng.random();
445
446 debug_assert!(
447 p.validate().is_ok(),
448 "chrome_148_macos_sampled produced an invalid profile: {:?}",
449 p.validate()
450 );
451
452 p
453}
454
455#[cfg(test)]
459pub mod presets {
460 use super::*;
461
462 pub fn chrome_147_macos() -> StealthProfile {
463 chrome_148_macos()
464 }
465 pub fn chrome_147_windows() -> StealthProfile {
466 chrome_148_windows()
467 }
468 pub fn chrome_147_linux() -> StealthProfile {
469 chrome_148_linux()
470 }
471 pub fn firefox_135_macos() -> StealthProfile {
472 super::firefox_135_macos()
473 }
474 pub fn safari_ios_18() -> StealthProfile {
475 iphone_15_pro_safari_18()
476 }
477 pub fn pixel_9_pro_chrome_148() -> StealthProfile {
478 super::pixel_9_pro_chrome_148()
479 }
480}
481
482#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn chrome_148_windows_validates() {
490 let p = chrome_148_windows();
491 assert!(p.validate().is_ok(), "{:?}", p.validate());
492 }
493
494 #[test]
495 fn chrome_148_macos_validates() {
496 let p = chrome_148_macos();
497 assert!(p.validate().is_ok(), "{:?}", p.validate());
498 }
499
500 #[test]
501 fn chrome_148_linux_validates() {
502 let p = chrome_148_linux();
503 assert!(p.validate().is_ok(), "{:?}", p.validate());
504 }
505
506 #[test]
507 fn chrome_148_ru_validates() {
508 let p = chrome_148_ru();
509 assert!(p.validate().is_ok(), "{:?}", p.validate());
510 }
511
512 #[test]
513 fn chrome_148_cn_validates() {
514 let p = chrome_148_cn();
515 assert!(p.validate().is_ok(), "{:?}", p.validate());
516 }
517
518 #[test]
519 fn firefox_135_macos_validates() {
520 let p = firefox_135_macos();
521 assert!(p.validate().is_ok(), "{:?}", p.validate());
522 assert_eq!(p.browser_name, "Firefox");
523 assert_eq!(p.vendor, "");
524 assert_eq!(p.product_sub, "20100101");
525 assert!(p.user_agent.contains("rv:135.0"));
526 assert!(p.user_agent.contains("Firefox/135.0"));
527 assert!(!p.user_agent.contains("Chrome"));
528 }
529
530 #[test]
531 fn firefox_135_windows_validates() {
532 let p = firefox_135_windows();
533 assert!(p.validate().is_ok(), "{:?}", p.validate());
534 assert!(p.user_agent.contains("Firefox/135.0"));
535 }
536
537 #[test]
538 fn firefox_135_linux_validates() {
539 let p = firefox_135_linux();
540 assert!(p.validate().is_ok(), "{:?}", p.validate());
541 assert!(p.user_agent.contains("Firefox/135.0"));
542 }
543
544 #[test]
545 fn pixel_9_pro_validates() {
546 let p = pixel_9_pro_chrome_148();
547 assert!(p.validate().is_ok(), "{:?}", p.validate());
548 }
549
550 #[test]
551 fn iphone_15_pro_validates() {
552 let p = iphone_15_pro_safari_18();
553 assert!(p.validate().is_ok(), "{:?}", p.validate());
554 }
555
556 #[test]
557 fn http3_disabled_by_default_on_all_presets() {
558 for profile in [
559 chrome_148_windows(),
560 chrome_148_macos(),
561 chrome_148_linux(),
562 chrome_148_ru(),
563 chrome_148_cn(),
564 chrome_148_de(),
565 chrome_148_jp(),
566 firefox_135_macos(),
567 firefox_135_windows(),
568 firefox_135_linux(),
569 ] {
570 assert!(
571 !profile.allow_http3,
572 "Profile sets allow_http3=true: {}",
573 profile.user_agent
574 );
575 }
576 }
577
578 #[test]
579 fn firefox_webgl_is_masked() {
580 for profile in [
581 firefox_135_macos(),
582 firefox_135_windows(),
583 firefox_135_linux(),
584 ] {
585 assert_eq!(profile.webgl_vendor, "Mozilla");
586 assert_eq!(profile.webgl_renderer, "Mozilla");
587 }
588 }
589
590 #[test]
591 fn random_desktop_validates() {
592 for _ in 0..10 {
593 let p = random_desktop();
594 assert!(p.validate().is_ok(), "{:?}", p.validate());
595 }
596 }
597
598 #[test]
599 fn random_desktop_diversity() {
600 use std::collections::HashSet;
601 let mut names = HashSet::new();
602 for _ in 0..30 {
603 let p = random_desktop();
604 names.insert(p.browser_name.clone());
605 }
606 assert!(!names.is_empty());
609 }
610
611 #[test]
612 fn invalid_profile_detected() {
613 let mut p = chrome_148_windows();
614 p.platform = "MacIntel".into();
615 assert!(p.validate().is_err());
616 }
617
618 #[test]
619 fn invalid_gpu_os_mismatch() {
620 let mut p = chrome_148_windows();
621 p.webgl_renderer =
622 "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)".into();
623 p.webgl_vendor = "Google Inc. (Apple)".into();
624 assert!(p.validate().is_err());
625 }
626
627 #[test]
628 fn ua_contains_version() {
629 let p = chrome_148_windows();
630 assert!(p.user_agent.contains("148.0.0.0"));
631 assert_eq!(p.browser_version, "148.0.7778.168");
632 }
633
634 #[test]
635 fn serialization_roundtrip() {
636 let p = chrome_148_windows();
637 let json = serde_json::to_string(&p).unwrap();
638 let deserialized: StealthProfile = serde_json::from_str(&json).unwrap();
639 assert_eq!(p.user_agent, deserialized.user_agent);
640 assert_eq!(p.screen_width, deserialized.screen_width);
641 }
642
643 #[test]
644 fn macos_sampler_produces_valid_profiles() {
645 for _ in 0..200 {
646 let p = chrome_148_macos_sampled();
647 p.validate()
648 .unwrap_or_else(|e| panic!("invalid sampled profile: {e:?}"));
649 assert!(matches!(p.screen_width, 1512 | 1728 | 1800 | 2056));
650 assert!(matches!(p.cpu_cores, 8 | 11 | 12 | 14 | 16));
651 assert!(matches!(p.device_memory, 8 | 16 | 18 | 24 | 36 | 48));
652 assert_eq!(p.device_pixel_ratio, 2.0);
653 assert_eq!(p.audio_sample_rate, 48000);
654 assert_eq!(p.cpu_architecture, "arm");
655 assert_eq!(p.platform, "MacIntel");
656 assert_eq!(p.inner_height + 111, p.screen_height);
657 }
658 }
659
660 #[test]
661 fn macos_sampler_keeps_cross_api_consistency() {
662 for _ in 0..50 {
663 let p = chrome_148_macos_sampled();
664 let r = &p.gpu_profile.unmasked_renderer;
665 match p.cpu_cores {
666 8 => {
667 assert!(r.contains("Apple M3,"));
668 assert!(matches!(p.device_memory, 8 | 16 | 24));
669 }
670 11 | 12 => {
671 assert!(r.contains("Apple M3 Pro"));
672 assert!(matches!(p.device_memory, 18 | 36));
673 }
674 14 | 16 => {
675 assert!(r.contains("Apple M3 Max"));
676 assert!(matches!(p.device_memory, 36 | 48));
677 }
678 other => panic!("unexpected cpu_cores {other}"),
679 }
680 assert_eq!(p.webgl_renderer, *r);
681 }
682 }
683
684 use rand_chacha::rand_core::SeedableRng;
687
688 fn fixed_rng() -> rand_chacha::ChaCha20Rng {
689 rand_chacha::ChaCha20Rng::seed_from_u64(42)
690 }
691
692 #[test]
693 fn behavior_profile_defaults_are_sensible() {
694 let p = BehaviorProfile::default();
695 assert!((30.0..=80.0).contains(&p.typing_wpm_mean));
696 assert!((130.0..=220.0).contains(&p.fitts_b));
697 assert_eq!(p.handedness, Handedness::Right);
698 }
699
700 #[test]
701 fn rng_for_is_deterministic_per_seed() {
702 let p = BehaviorProfile {
703 seed: 99,
704 ..BehaviorProfile::default()
705 };
706 let mut a = p.rng_for(123);
707 let mut b = p.rng_for(123);
708 use rand::RngExt;
709 assert_eq!(a.random::<u64>(), b.random::<u64>());
710 }
711
712 #[test]
713 fn rng_for_differs_across_salts() {
714 let p = BehaviorProfile {
715 seed: 99,
716 ..BehaviorProfile::default()
717 };
718 let mut a = p.rng_for(1);
719 let mut b = p.rng_for(2);
720 use rand::RngExt;
721 assert_ne!(a.random::<u64>(), b.random::<u64>());
722 }
723
724 #[test]
725 fn mouse_trajectory_starts_at_from_and_ends_at_to() {
726 let p = BehaviorProfile {
727 seed: 42,
728 ..BehaviorProfile::default()
729 };
730 let pts = mouse_trajectory((100.0, 100.0), (500.0, 400.0), 50.0, &p);
731 assert!(pts.len() > 5);
732 let first = pts[0];
733 let last = pts[pts.len() - 1];
734 assert!((first.x - 100.0).abs() < 10.0, "first x={}", first.x);
735 assert!((first.y - 100.0).abs() < 10.0, "first y={}", first.y);
736 assert_eq!(last.x, 500.0);
737 assert_eq!(last.y, 400.0);
738 }
739
740 #[test]
741 fn mouse_trajectory_obeys_fitts_law_total_time() {
742 let p = BehaviorProfile {
743 seed: 42,
744 ..BehaviorProfile::default()
745 };
746 let pts = mouse_trajectory((0.0, 0.0), (500.0, 0.0), 50.0, &p);
747 let last_t = pts[pts.len() - 1].t_ms;
748 assert!(
749 (700.0..=950.0).contains(&last_t),
750 "expected ~805 ms, got {last_t}"
751 );
752 }
753
754 #[test]
755 fn mouse_trajectory_uses_8ms_sample_rate() {
756 let p = BehaviorProfile {
757 seed: 42,
758 ..BehaviorProfile::default()
759 };
760 let pts = mouse_trajectory((0.0, 0.0), (200.0, 0.0), 30.0, &p);
761 for w in pts.windows(2) {
762 let dt = w[1].t_ms - w[0].t_ms;
763 assert!((dt - 8.0).abs() < 1e-3, "gap {} not 8 ms", dt);
764 }
765 }
766
767 #[test]
768 fn mouse_trajectory_has_velocity_diversity() {
769 let p = BehaviorProfile {
770 seed: 42,
771 ..BehaviorProfile::default()
772 };
773 let mut rng = fixed_rng();
774 let pts = mouse_trajectory_with_rng((0.0, 0.0), (600.0, 400.0), 40.0, &p, &mut rng);
775 let speeds: Vec<f32> = pts
776 .windows(2)
777 .map(|w| ((w[1].x - w[0].x).powi(2) + (w[1].y - w[0].y).powi(2)).sqrt())
778 .collect();
779 let mean = speeds.iter().sum::<f32>() / speeds.len() as f32;
780 let var = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / speeds.len() as f32;
781 let std = var.sqrt();
782 let cv = std / mean.max(1e-3);
783 assert!(cv > 0.4, "speed CV too low: {cv}");
784 }
785
786 #[test]
787 fn mouse_trajectory_deterministic_per_seed() {
788 let p = BehaviorProfile {
789 seed: 123,
790 ..BehaviorProfile::default()
791 };
792 let mut r1 = p.rng_for(1);
793 let mut r2 = p.rng_for(1);
794 let a = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r1);
795 let b = mouse_trajectory_with_rng((0.0, 0.0), (300.0, 200.0), 25.0, &p, &mut r2);
796 assert_eq!(a.len(), b.len());
797 for (pa, pb) in a.iter().zip(b.iter()) {
798 assert_eq!(pa, pb);
799 }
800 }
801
802 #[test]
803 fn mouse_trajectory_no_endpoint_jerk_spike() {
804 for seed in 0..40u64 {
805 let p = BehaviorProfile {
806 seed,
807 ..BehaviorProfile::default()
808 };
809 let mut r = p.rng_for(2);
810 let tr = mouse_trajectory_with_rng((12.0, 30.0), (840.0, 510.0), 28.0, &p, &mut r);
811 assert!(tr.len() >= 8);
812 let step =
813 |a: &MousePoint, b: &MousePoint| ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt();
814 let steps: Vec<f32> = tr.windows(2).map(|w| step(&w[0], &w[1])).collect();
815 let n = steps.len();
816 let final_step = steps[n - 1];
817 let mut sorted = steps.clone();
818 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
819 let median = sorted[n / 2];
820 let max_step = sorted[n - 1];
821 assert!(
822 final_step <= max_step + 1e-3,
823 "seed {seed}: final step {final_step} exceeds max interior {max_step}"
824 );
825 assert!(
826 final_step <= median * 6.0 + 5.0,
827 "seed {seed}: final step {final_step} is jerk outlier vs median {median}"
828 );
829 let last = tr.last().unwrap();
830 assert!((last.x - 840.0).abs() < 1e-2 && (last.y - 510.0).abs() < 1e-2);
831 }
832 }
833
834 #[test]
835 fn keystroke_first_has_no_flight() {
836 let p = BehaviorProfile {
837 seed: 42,
838 ..BehaviorProfile::default()
839 };
840 let ks = keystroke_timings("hi", &p);
841 assert_eq!(ks[0].flight_ms, 0.0);
842 assert!(ks[1].flight_ms > 0.0);
843 }
844
845 #[test]
846 fn keystroke_dwell_in_realistic_range() {
847 let p = BehaviorProfile {
848 seed: 42,
849 ..BehaviorProfile::default()
850 };
851 let ks = keystroke_timings("the quick brown fox jumps over the lazy dog", &p);
852 let mean_dwell: f32 = ks.iter().map(|k| k.dwell_ms).sum::<f32>() / ks.len() as f32;
853 assert!(
854 (70.0..=150.0).contains(&mean_dwell),
855 "mean dwell {mean_dwell} outside plausible range"
856 );
857 }
858
859 #[test]
860 fn keystroke_flight_scales_with_wpm() {
861 let slow = BehaviorProfile {
862 seed: 42,
863 typing_wpm_mean: 30.0,
864 ..BehaviorProfile::default()
865 };
866 let fast = BehaviorProfile {
867 seed: 42,
868 typing_wpm_mean: 70.0,
869 ..BehaviorProfile::default()
870 };
871 let s = keystroke_timings("the quick brown fox jumps over", &slow);
872 let f = keystroke_timings("the quick brown fox jumps over", &fast);
873 let mean = |ks: &[KeystrokeTiming]| -> f32 {
874 ks.iter().skip(1).map(|k| k.flight_ms).sum::<f32>() / (ks.len() - 1) as f32
875 };
876 assert!(
877 mean(&s) > mean(&f),
878 "30 WPM flight {} should exceed 70 WPM flight {}",
879 mean(&s),
880 mean(&f)
881 );
882 }
883
884 #[test]
885 fn keystroke_bigram_th_faster_than_dd() {
886 let mut th_total = 0.0_f32;
887 let mut dd_total = 0.0_f32;
888 for seed in 0..50 {
889 let prof = BehaviorProfile {
890 seed,
891 ..BehaviorProfile::default()
892 };
893 let th = keystroke_timings("th", &prof);
894 let dd = keystroke_timings("dd", &prof);
895 th_total += th[1].flight_ms;
896 dd_total += dd[1].flight_ms;
897 }
898 let th_mean = th_total / 50.0;
899 let dd_mean = dd_total / 50.0;
900 assert!(
901 dd_mean > th_mean * 1.5,
902 "dd flight {dd_mean} should be > 1.5× th flight {th_mean}"
903 );
904 }
905
906 #[test]
907 fn keystroke_deterministic_per_seed() {
908 let mut rng_a = rand_chacha::ChaCha20Rng::seed_from_u64(7);
909 let mut rng_b = rand_chacha::ChaCha20Rng::seed_from_u64(7);
910 let p = BehaviorProfile::default();
911 let a = keystroke_timings_with_rng("hello world", &p, &mut rng_a);
912 let b = keystroke_timings_with_rng("hello world", &p, &mut rng_b);
913 assert_eq!(a, b);
914 }
915
916 #[test]
917 fn trackpad_burst_decays_to_zero() {
918 let p = BehaviorProfile {
919 seed: 42,
920 scroll_style: ScrollStyle::Trackpad,
921 ..BehaviorProfile::default()
922 };
923 let ticks = wheel_burst(-1000.0, &p);
924 assert!(ticks.len() > 5);
925 for t in &ticks {
926 assert_eq!(t.mode, 0);
927 assert!(t.delta_y < 0.0);
928 }
929 let cum: f32 = ticks.iter().map(|t| t.delta_y).sum();
930 assert!(
931 (cum + 1000.0).abs() < 200.0,
932 "cumulative {cum} not close to -1000"
933 );
934 for w in ticks.windows(2) {
935 let dt = w[1].t_ms - w[0].t_ms;
936 assert!((dt - 16.0).abs() < 1e-3);
937 }
938 }
939
940 #[test]
941 fn wheel_burst_uses_100px_notches() {
942 let p = BehaviorProfile {
943 seed: 42,
944 scroll_style: ScrollStyle::Wheel,
945 ..BehaviorProfile::default()
946 };
947 let ticks = wheel_burst(500.0, &p);
948 assert_eq!(ticks.len(), 5);
949 for t in &ticks {
950 assert_eq!(t.delta_y, 100.0);
951 assert_eq!(t.mode, 0);
952 }
953 }
954
955 #[test]
956 fn wheel_burst_intervals_are_lognormal_distributed() {
957 let p = BehaviorProfile {
958 seed: 42,
959 scroll_style: ScrollStyle::Wheel,
960 ..BehaviorProfile::default()
961 };
962 let ticks = wheel_burst(2000.0, &p);
963 let intervals: Vec<f32> = ticks.windows(2).map(|w| w[1].t_ms - w[0].t_ms).collect();
964 let mean = intervals.iter().sum::<f32>() / intervals.len() as f32;
965 assert!(
966 (mean - 180.0).abs() < 200.0,
967 "mean interval {mean} too far from 180 ms"
968 );
969 let mut sorted = intervals.clone();
970 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
971 sorted.dedup_by(|a, b| (*a - *b).abs() < 1e-3);
972 assert!(sorted.len() > 5, "only {} distinct intervals", sorted.len());
973 }
974
975 #[test]
976 fn default_seeds_differ_across_instances() {
977 let a = BehaviorProfile::default();
978 let b = BehaviorProfile::default();
979 assert_ne!(a.seed, b.seed);
980 }
981}