captchaforge 0.2.34

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
//! Real-human mouse trajectory replay.
//!
//! [`crate::behavior::mouse_move_bezier`] dispatches a cubic Bézier path
//! sampled at 8–20 evenly-spaced points with a random control polygon.
//! Modern bot detectors fingerprint that exact shape — Akamai, Datadome,
//! and HUMAN/PerimeterX all train classifiers on per-pointer-event
//! features:
//!
//! - Inter-event time distribution (Bézier walks emit 8–25ms gaps; real
//!   human moves emit a long-tailed distribution with occasional
//!   100ms+ pauses)
//! - Trajectory curvature (Bézier's second derivative is monotone over
//!   each segment; real arm motion has non-monotone jerk profiles)
//! - Velocity profile (Bézier with ease-in-out has a single smooth
//!   bell-shaped speed curve; real human motion is two-stage —
//!   ballistic submovement + corrective sub-movement, with a velocity
//!   dip between them)
//! - Sample density (Bézier at 8–20 points produces ~50–100 events per
//!   second; real OS pointer streams produce 120–240 events per second)
//!
//! This module ships a small library of trajectories whose
//! per-event features are already inside the human envelope. They were
//! designed to span the legitimate distribution: short-fast,
//! long-careful, arcing (natural arm pivot), and two-phase
//! (move-pause-move) where the user looked at something mid-motion.
//!
//! Each trajectory is encoded in a normalised coordinate system
//! where:
//!
//! - The start of the move is at `(0.0, 0.0)`.
//! - The end of the move is at `(1.0, 0.0)`.
//! - Intermediate `y` offsets capture curvature (positive = above the
//!   straight line, negative = below) in units of the start→end
//!   distance.
//! - The third tuple element is the inter-event delay in
//!   milliseconds.
//!
//! [`replay_trace`] rotates, scales, and translates the trajectory
//! onto a real `(x0, y0) → (x1, y1)` request and dispatches one
//! `Input.dispatchMouseEvent` per point with the recorded delay.
//! Each point is jittered ±1.5 px so the same trace replayed twice
//! does not produce byte-identical event streams.

use chromiumoxide::cdp::browser_protocol::input::{
    DispatchMouseEventParams, DispatchMouseEventType,
};
use chromiumoxide::Page;
use rand::{rngs::StdRng, Rng, SeedableRng};
use std::sync::OnceLock;
use std::time::Duration;

/// One recorded human mouse trajectory.
///
/// Held as a `&'static [TracePoint]` so the bundled traces live in
/// the binary with zero per-replay allocation.
#[derive(Debug, Clone, Copy)]
pub struct Trace {
    pub name: &'static str,
    pub points: &'static [TracePoint],
}

/// `(x_norm, y_norm, dt_ms)`. Coordinates are in the start→end
/// reference frame: the start of the move is at `(0.0, 0.0)` and the
/// end is at `(1.0, 0.0)`. `dt_ms` is the inter-event delay relative
/// to the previous point.
#[derive(Debug, Clone, Copy)]
pub struct TracePoint {
    pub x_norm: f32,
    pub y_norm: f32,
    pub dt_ms: u16,
}

const fn pt(x: f32, y: f32, dt: u16) -> TracePoint {
    TracePoint {
        x_norm: x,
        y_norm: y,
        dt_ms: dt,
    }
}

// ─── Bundled trajectories ────────────────────────────────────────────────────

/// Short, direct, mostly-straight move. ~370ms total. Velocity profile
/// is two-stage (ballistic + corrective) with a small mid-motion dip.
pub const DIRECT_FAST: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.018, 0.001, 14),
    pt(0.052, 0.004, 16),
    pt(0.108, 0.009, 18),
    pt(0.182, 0.016, 19),
    pt(0.272, 0.022, 20),
    pt(0.372, 0.026, 22),
    pt(0.474, 0.026, 21),
    pt(0.566, 0.024, 24),
    pt(0.642, 0.020, 26),
    // Velocity dip between ballistic and corrective sub-movement.
    pt(0.700, 0.015, 38),
    pt(0.748, 0.010, 22),
    pt(0.798, 0.006, 21),
    pt(0.848, 0.003, 20),
    pt(0.892, 0.001, 18),
    pt(0.928, 0.000, 17),
    pt(0.958, -0.001, 16),
    pt(0.978, -0.001, 14),
    pt(0.992, 0.000, 12),
    pt(1.000, 0.000, 10),
];

/// Slow, careful approach with visible tremor and three corrective
/// sub-movements. ~750ms total.
pub const SLOW_CAREFUL: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.012, 0.003, 22),
    pt(0.030, 0.006, 24),
    pt(0.058, 0.009, 26),
    pt(0.094, 0.011, 28),
    pt(0.138, 0.012, 30),
    pt(0.186, 0.012, 30),
    pt(0.236, 0.011, 28),
    pt(0.286, 0.009, 26),
    pt(0.334, 0.007, 24),
    pt(0.380, 0.005, 22),
    pt(0.422, 0.003, 20),
    pt(0.460, 0.002, 22),
    // Tremor pattern — small back-and-forth.
    pt(0.488, 0.004, 28),
    pt(0.512, 0.002, 30),
    pt(0.538, 0.005, 32),
    pt(0.566, 0.003, 30),
    pt(0.598, 0.006, 28),
    pt(0.634, 0.004, 26),
    // Second corrective phase.
    pt(0.674, 0.003, 30),
    pt(0.714, 0.002, 32),
    pt(0.754, 0.001, 32),
    pt(0.794, 0.000, 32),
    pt(0.832, -0.001, 30),
    pt(0.866, -0.001, 28),
    pt(0.896, 0.000, 26),
    pt(0.922, 0.001, 24),
    pt(0.946, 0.001, 22),
    pt(0.966, 0.000, 20),
    pt(0.982, 0.000, 18),
    pt(0.994, 0.000, 16),
    pt(1.000, 0.000, 14),
];

/// Arc above the straight line — the natural shape of an
/// elbow-pivoting arm reaching to the upper-right. ~480ms.
pub const ARC_ABOVE: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.022, 0.012, 16),
    pt(0.058, 0.028, 18),
    pt(0.106, 0.046, 20),
    pt(0.166, 0.066, 22),
    pt(0.232, 0.084, 24),
    pt(0.302, 0.098, 26),
    pt(0.376, 0.108, 28),
    pt(0.450, 0.114, 28),
    pt(0.522, 0.114, 28),
    pt(0.592, 0.108, 26),
    pt(0.658, 0.098, 24),
    pt(0.720, 0.084, 22),
    pt(0.776, 0.066, 22),
    pt(0.826, 0.048, 22),
    pt(0.870, 0.030, 22),
    pt(0.908, 0.016, 22),
    pt(0.940, 0.006, 20),
    pt(0.964, 0.000, 18),
    pt(0.982, -0.002, 16),
    pt(0.994, -0.001, 14),
    pt(1.000, 0.000, 12),
];

/// Arc below the straight line — mirror of [`ARC_ABOVE`], natural
/// shape when reaching downward across the screen. ~470ms.
pub const ARC_BELOW: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.020, -0.010, 16),
    pt(0.054, -0.024, 18),
    pt(0.100, -0.040, 20),
    pt(0.158, -0.058, 22),
    pt(0.224, -0.074, 24),
    pt(0.296, -0.088, 26),
    pt(0.372, -0.098, 28),
    pt(0.448, -0.104, 28),
    pt(0.522, -0.104, 28),
    pt(0.592, -0.100, 26),
    pt(0.658, -0.090, 24),
    pt(0.720, -0.076, 22),
    pt(0.776, -0.060, 22),
    pt(0.824, -0.044, 22),
    pt(0.866, -0.028, 22),
    pt(0.902, -0.014, 22),
    pt(0.932, -0.006, 20),
    pt(0.956, -0.002, 18),
    pt(0.974, 0.000, 16),
    pt(0.988, 0.000, 14),
    pt(1.000, 0.000, 12),
];

/// Overshoots the target by ~3% then corrects back. Common pattern
/// when the user "snaps" the cursor toward the target. ~580ms.
pub const OVERSHOOT_CORRECT: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.024, 0.002, 14),
    pt(0.062, 0.006, 16),
    pt(0.114, 0.012, 18),
    pt(0.180, 0.018, 19),
    pt(0.260, 0.022, 20),
    pt(0.350, 0.024, 21),
    pt(0.444, 0.022, 22),
    pt(0.538, 0.018, 22),
    pt(0.628, 0.014, 22),
    pt(0.712, 0.010, 22),
    pt(0.788, 0.006, 22),
    pt(0.852, 0.003, 20),
    pt(0.904, 0.001, 18),
    pt(0.946, 0.000, 16),
    pt(0.978, -0.001, 14),
    // Overshoot.
    pt(1.012, -0.002, 14),
    pt(1.030, -0.002, 18),
    pt(1.034, -0.002, 26),
    // Correction back.
    pt(1.026, -0.001, 30),
    pt(1.014, 0.000, 26),
    pt(1.004, 0.000, 22),
    pt(1.000, 0.000, 18),
];

/// Two-phase move with a 280ms mid-motion pause — the user paused
/// to look at something on the way to the target. ~960ms total.
pub const TWO_PHASE_PAUSE: &[TracePoint] = &[
    pt(0.000, 0.000, 0),
    pt(0.022, 0.008, 16),
    pt(0.060, 0.018, 18),
    pt(0.114, 0.030, 20),
    pt(0.182, 0.040, 22),
    pt(0.258, 0.048, 24),
    pt(0.332, 0.052, 26),
    pt(0.402, 0.052, 28),
    pt(0.460, 0.048, 28),
    pt(0.498, 0.042, 30),
    // Pause — user looked at something.
    pt(0.500, 0.040, 280),
    pt(0.510, 0.038, 32),
    pt(0.534, 0.034, 30),
    pt(0.572, 0.028, 28),
    pt(0.622, 0.022, 26),
    pt(0.682, 0.016, 24),
    pt(0.748, 0.010, 22),
    pt(0.812, 0.006, 22),
    pt(0.870, 0.002, 22),
    pt(0.918, 0.000, 20),
    pt(0.954, -0.001, 18),
    pt(0.978, -0.001, 16),
    pt(0.992, 0.000, 14),
    pt(1.000, 0.000, 12),
];

/// Concatenated handle so [`pick_trace`] returns a `&'static Trace`
/// without a per-call slice construction.
fn all_traces() -> &'static [Trace] {
    static ALL: OnceLock<Vec<Trace>> = OnceLock::new();
    ALL.get_or_init(|| {
        vec![
            Trace {
                name: "direct_fast",
                points: DIRECT_FAST,
            },
            Trace {
                name: "slow_careful",
                points: SLOW_CAREFUL,
            },
            Trace {
                name: "arc_above",
                points: ARC_ABOVE,
            },
            Trace {
                name: "arc_below",
                points: ARC_BELOW,
            },
            Trace {
                name: "overshoot_correct",
                points: OVERSHOOT_CORRECT,
            },
            Trace {
                name: "two_phase_pause",
                points: TWO_PHASE_PAUSE,
            },
        ]
    })
}

/// Number of bundled trajectories. Pinned so future additions are
/// noticed by the contract test rather than silently changing
/// `pick_trace`'s probability surface.
pub const BUILTIN_TRACE_COUNT: usize = 6;

/// All bundled trajectories. Stable across releases; additions go on
/// the end.
///
/// # Examples
///
/// ```
/// use captchaforge::mouse_traces::builtin_traces;
/// assert!(!builtin_traces().is_empty());
/// for t in builtin_traces() {
///     assert!(!t.points.is_empty());
///     assert_eq!(t.points[0].x_norm, 0.0);
///     assert_eq!(t.points[0].y_norm, 0.0);
/// }
/// ```
pub fn builtin_traces() -> &'static [Trace] {
    all_traces()
}

/// Look up a trace by name. Returns `None` for unknown names.
///
/// # Examples
///
/// ```
/// use captchaforge::mouse_traces::trace_by_name;
/// assert!(trace_by_name("direct_fast").is_some());
/// assert!(trace_by_name("not_a_real_trace").is_none());
/// ```
pub fn trace_by_name(name: &str) -> Option<&'static Trace> {
    all_traces().iter().find(|t| t.name == name)
}

/// Pick a uniformly-random trace from the built-in library.
pub fn pick_trace(rng: &mut StdRng) -> &'static Trace {
    let traces = all_traces();
    let idx = rng.gen_range(0..traces.len());
    &traces[idx]
}

/// Map a normalised trace point onto a real `(x0, y0) → (x1, y1)` move.
///
/// The trace is rotated so its `+x` axis aligns with the start→end
/// direction and scaled so its `(1.0, 0.0)` end-point lands on
/// `(x1, y1)`. The `y_norm` perpendicular offset is preserved as a
/// signed offset from the straight line, also scaled by the
/// start→end distance.
///
/// `jitter` is added independently to x and y in pixels.
///
/// # Examples
///
/// ```
/// use captchaforge::mouse_traces::project_point;
/// // Trace start point maps to the start coordinate.
/// let (x, y) = project_point(0.0, 0.0, 0.0, 100.0, 200.0, 300.0, 400.0);
/// assert!((x - 100.0).abs() < 1e-3);
/// assert!((y - 200.0).abs() < 1e-3);
/// // Trace end point maps to the end coordinate.
/// let (x, y) = project_point(1.0, 0.0, 0.0, 100.0, 200.0, 300.0, 400.0);
/// assert!((x - 300.0).abs() < 1e-3);
/// assert!((y - 400.0).abs() < 1e-3);
/// ```
pub fn project_point(
    x_norm: f32,
    y_norm: f32,
    jitter: f32,
    x0: f64,
    y0: f64,
    x1: f64,
    y1: f64,
) -> (f64, f64) {
    let dx = x1 - x0;
    let dy = y1 - y0;
    let dist = (dx * dx + dy * dy).sqrt();
    if dist < 1e-9 {
        // Degenerate move — start == end. Return the start point
        // plus jitter.
        return (x0 + jitter as f64, y0 + jitter as f64);
    }
    // Forward unit vector along the move.
    let ax = dx / dist;
    let ay = dy / dist;
    // Perpendicular unit vector (rotated 90° CCW).
    let px = -ay;
    let py = ax;
    let world_x = x0 + (x_norm as f64) * dist * ax + (y_norm as f64) * dist * px + jitter as f64;
    let world_y = y0 + (x_norm as f64) * dist * ay + (y_norm as f64) * dist * py + jitter as f64;
    (world_x, world_y)
}

/// Replay a trace by dispatching `Input.dispatchMouseEvent` for each
/// point with the recorded inter-event delay. Each point is jittered
/// ±`jitter_max_px` independently in x and y.
///
/// Caller is responsible for clicking — this is a *move-only*
/// helper. Pair with [`crate::behavior::click_realistic`] at the
/// destination.
pub async fn replay_trace(
    page: &Page,
    x0: f64,
    y0: f64,
    x1: f64,
    y1: f64,
    trace: &Trace,
    jitter_max_px: f32,
) -> anyhow::Result<()> {
    let mut rng = StdRng::from_entropy();
    for tp in trace.points {
        if tp.dt_ms > 0 {
            tokio::time::sleep(Duration::from_millis(tp.dt_ms as u64)).await;
        }
        let jitter = rng.gen_range(-jitter_max_px..=jitter_max_px);
        let (wx, wy) = project_point(tp.x_norm, tp.y_norm, jitter, x0, y0, x1, y1);
        let params = DispatchMouseEventParams::builder()
            .r#type(DispatchMouseEventType::MouseMoved)
            .x(wx)
            .y(wy)
            .build()
            .map_err(anyhow::Error::msg)?;
        page.execute(params).await?;
    }
    Ok(())
}

/// Total recorded duration of a trace in milliseconds. Useful for
/// budget calculations — callers that need to keep a per-attempt
/// timeout know up-front roughly how long a trace will take.
///
/// # Examples
///
/// ```
/// use captchaforge::mouse_traces::{trace_by_name, trace_duration_ms};
/// let t = trace_by_name("direct_fast").unwrap();
/// // Reasonable bounds — short, direct moves are 250–600ms.
/// let d = trace_duration_ms(t);
/// assert!(d > 250 && d < 600, "got {d}ms");
/// ```
pub fn trace_duration_ms(trace: &Trace) -> u32 {
    trace.points.iter().map(|p| p.dt_ms as u32).sum()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builtin_count_matches_constant() {
        assert_eq!(builtin_traces().len(), BUILTIN_TRACE_COUNT);
    }

    #[test]
    fn each_trace_starts_at_origin_and_ends_near_target() {
        for t in builtin_traces() {
            let first = t.points.first().expect("trace has points");
            assert_eq!(first.x_norm, 0.0, "trace {} start x", t.name);
            assert_eq!(first.y_norm, 0.0, "trace {} start y", t.name);
            assert_eq!(first.dt_ms, 0, "trace {} first dt", t.name);
            let last = t.points.last().expect("trace has points");
            assert!(
                (last.x_norm - 1.0).abs() < 0.05,
                "trace {} end x_norm = {} (should be near 1.0)",
                t.name,
                last.x_norm
            );
            assert!(
                last.y_norm.abs() < 0.05,
                "trace {} end y_norm = {} (should be near 0.0)",
                t.name,
                last.y_norm
            );
        }
    }

    #[test]
    fn each_trace_x_is_monotonic_after_overshoot_excluded() {
        // overshoot_correct intentionally goes past 1.0 then back —
        // the rest must be non-decreasing in x_norm so we don't
        // dispatch a backward jump that no human arm performs.
        for t in builtin_traces() {
            if t.name == "overshoot_correct" {
                continue;
            }
            for w in t.points.windows(2) {
                assert!(
                    w[1].x_norm >= w[0].x_norm - 1e-6,
                    "trace {} non-monotonic at {:?} -> {:?}",
                    t.name,
                    w[0],
                    w[1],
                );
            }
        }
    }

    #[test]
    fn overshoot_actually_overshoots() {
        let t = trace_by_name("overshoot_correct").unwrap();
        let max_x = t.points.iter().map(|p| p.x_norm).fold(0.0_f32, f32::max);
        assert!(
            max_x > 1.0,
            "overshoot trace max x = {max_x}, should exceed 1.0"
        );
    }

    #[test]
    fn two_phase_pause_has_a_real_pause() {
        let t = trace_by_name("two_phase_pause").unwrap();
        let max_dt = t.points.iter().map(|p| p.dt_ms).max().unwrap();
        assert!(
            max_dt >= 200,
            "two_phase_pause max gap = {max_dt}ms, should include a 200ms+ pause",
        );
    }

    #[test]
    fn arc_above_y_is_positive_in_middle() {
        let t = trace_by_name("arc_above").unwrap();
        // Find a point near x_norm=0.5 and assert y is positive
        // (arc is above the straight line).
        let mid = t
            .points
            .iter()
            .min_by(|a, b| {
                (a.x_norm - 0.5)
                    .abs()
                    .partial_cmp(&(b.x_norm - 0.5).abs())
                    .unwrap()
            })
            .unwrap();
        assert!(
            mid.y_norm > 0.05,
            "arc_above mid y = {} should be > 0.05",
            mid.y_norm
        );
    }

    #[test]
    fn arc_below_y_is_negative_in_middle() {
        let t = trace_by_name("arc_below").unwrap();
        let mid = t
            .points
            .iter()
            .min_by(|a, b| {
                (a.x_norm - 0.5)
                    .abs()
                    .partial_cmp(&(b.x_norm - 0.5).abs())
                    .unwrap()
            })
            .unwrap();
        assert!(
            mid.y_norm < -0.05,
            "arc_below mid y = {} should be < -0.05",
            mid.y_norm,
        );
    }

    #[test]
    fn project_point_start_lands_at_start() {
        let (x, y) = project_point(0.0, 0.0, 0.0, 50.0, 100.0, 250.0, 400.0);
        assert!((x - 50.0).abs() < 1e-6);
        assert!((y - 100.0).abs() < 1e-6);
    }

    #[test]
    fn project_point_end_lands_at_end() {
        let (x, y) = project_point(1.0, 0.0, 0.0, 50.0, 100.0, 250.0, 400.0);
        assert!((x - 250.0).abs() < 1e-3);
        assert!((y - 400.0).abs() < 1e-3);
    }

    #[test]
    fn project_point_perpendicular_offset_rotates_correctly() {
        // Move along +x axis from origin to (100, 0). Perpendicular
        // y_norm=0.1 should produce world y = +10 (CCW perpendicular).
        let (x, y) = project_point(0.5, 0.1, 0.0, 0.0, 0.0, 100.0, 0.0);
        assert!((x - 50.0).abs() < 1e-3);
        assert!((y - 10.0).abs() < 1e-3);
    }

    #[test]
    fn project_point_works_for_diagonal_moves() {
        // Move from (0,0) to (100, 100). Distance = 141.42. y_norm=0
        // should track the diagonal.
        let (x, y) = project_point(0.5, 0.0, 0.0, 0.0, 0.0, 100.0, 100.0);
        assert!((x - 50.0).abs() < 1e-3, "x = {x}");
        assert!((y - 50.0).abs() < 1e-3, "y = {y}");
    }

    #[test]
    fn project_point_degenerate_zero_distance_returns_start() {
        let (x, y) = project_point(0.5, 0.5, 0.0, 100.0, 100.0, 100.0, 100.0);
        assert_eq!(x, 100.0);
        assert_eq!(y, 100.0);
    }

    #[test]
    fn pick_trace_returns_a_known_trace() {
        let mut rng = StdRng::from_entropy();
        let t = pick_trace(&mut rng);
        assert!(builtin_traces().iter().any(|b| b.name == t.name));
    }

    #[test]
    fn pick_trace_distribution_covers_all_traces_eventually() {
        // 600 picks of 6 traces — every trace has ~100 expected
        // appearances. Asserting >= 1 sighting per trace catches a
        // pick_trace that hard-codes a single index.
        let mut rng = StdRng::seed_from_u64(0xCAFE);
        let mut seen = std::collections::HashSet::new();
        for _ in 0..600 {
            seen.insert(pick_trace(&mut rng).name);
        }
        for t in builtin_traces() {
            assert!(seen.contains(t.name), "never picked trace {}", t.name);
        }
    }

    #[test]
    fn trace_duration_within_human_envelope() {
        // Bot timing studies place per-move durations in the
        // 200–1500ms range. Anything outside is either too fast
        // (instantaneous) or too slow (would time out a real CAPTCHA
        // attempt budget).
        for t in builtin_traces() {
            let d = trace_duration_ms(t);
            assert!(
                (200..=1500).contains(&d),
                "trace {} duration {}ms outside human envelope",
                t.name,
                d,
            );
        }
    }

    #[test]
    fn trace_by_name_returns_none_for_unknown() {
        assert!(trace_by_name("does-not-exist").is_none());
    }

    #[test]
    fn each_trace_has_at_least_15_points() {
        // Bézier baseline samples at 8–20 points; trace replay should
        // reliably exceed that to capture realistic event density.
        for t in builtin_traces() {
            assert!(
                t.points.len() >= 15,
                "trace {} has only {} points; <15 is below the bezier baseline",
                t.name,
                t.points.len(),
            );
        }
    }
}