captchaforge 0.2.33

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Deep property tests for PatternStore statistical correctness.

use captchaforge::solver::{CaptchaPattern, CaptchaType, PatternStore, SolveMethod};
use proptest::prelude::*;
use proptest::test_runner::TestRunner;

#[test]
fn pattern_avg_solve_time_stays_within_observed_range() {
    // Bounded-α EMA replaced the cumulative-mean property
    // (avg_solve_time = sum(t)/n). What still holds: the EMA value is
    // bounded by the observed min/max — it cannot drift outside the
    // range of inputs even after many samples.
    let mut runner = TestRunner::default();
    runner
        .run(
            &proptest::collection::vec(100u64..1000u64, 1..100),
            |times| {
                let mut pattern = CaptchaPattern::new(
                    "example.com".to_string(),
                    CaptchaType::CloudflareTurnstile,
                    SolveMethod::BehavioralBypass,
                );
                for &t in &times {
                    pattern.record(true, t, SolveMethod::BehavioralBypass);
                }
                let min_t = *times.iter().min().unwrap();
                let max_t = *times.iter().max().unwrap();
                prop_assert!(
                    pattern.avg_solve_time >= min_t && pattern.avg_solve_time <= max_t,
                    "EMA avg_solve_time {} should be in [{min_t}, {max_t}]",
                    pattern.avg_solve_time,
                );
                Ok(())
            },
        )
        .unwrap();
}

#[test]
fn pattern_success_rate_exact_for_all_success() {
    let mut pattern = CaptchaPattern::new(
        "example.com".to_string(),
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for _ in 0..100 {
        pattern.record(true, 100, SolveMethod::BehavioralBypass);
    }
    assert_eq!(pattern.success_rate, 1.0);
}

#[test]
fn pattern_success_rate_exact_for_all_failure() {
    let mut pattern = CaptchaPattern::new(
        "example.com".to_string(),
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for _ in 0..100 {
        pattern.record(false, 100, SolveMethod::BehavioralBypass);
    }
    assert_eq!(pattern.success_rate, 0.0);
}

#[test]
fn pattern_store_isolation_per_domain() {
    let store = PatternStore::default();
    store.record(
        "a.com",
        &CaptchaType::RecaptchaV2,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    store.record(
        "b.com",
        &CaptchaType::RecaptchaV2,
        false,
        5000,
        SolveMethod::VisionLLM,
    );

    let best_a = store.best_method("a.com", &CaptchaType::RecaptchaV2);
    let best_b = store.best_method("b.com", &CaptchaType::RecaptchaV2);

    assert_eq!(best_a, Some(SolveMethod::BehavioralBypass));
    assert_eq!(best_b, Some(SolveMethod::VisionLLM));
}

#[test]
fn pattern_store_isolation_per_type() {
    let store = PatternStore::default();
    store.record(
        "example.com",
        &CaptchaType::RecaptchaV2,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    store.record(
        "example.com",
        &CaptchaType::RecaptchaV3,
        false,
        5000,
        SolveMethod::VisionLLM,
    );

    let best_v2 = store.best_method("example.com", &CaptchaType::RecaptchaV2);
    let best_v3 = store.best_method("example.com", &CaptchaType::RecaptchaV3);

    assert_eq!(best_v2, Some(SolveMethod::BehavioralBypass));
    assert_eq!(best_v3, Some(SolveMethod::VisionLLM));
}

#[test]
fn pattern_store_ties_break_to_faster_method() {
    // Per-method EMA gives both records success_rate=1.0; tie-break is
    // faster avg_solve_time. Behavioral at 100ms beats Vision at 200ms.
    let store = PatternStore::default();
    store.record(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    store.record(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        200,
        SolveMethod::VisionLLM,
    );

    let best = store.best_method("example.com", &CaptchaType::CloudflareTurnstile);
    assert_eq!(best, Some(SolveMethod::BehavioralBypass));
}

#[test]
fn pattern_store_best_method_not_updated_on_failure() {
    let store = PatternStore::default();
    store.record(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    store.record(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        false,
        200,
        SolveMethod::VisionLLM,
    );

    let best = store.best_method("example.com", &CaptchaType::CloudflareTurnstile);
    assert_eq!(best, Some(SolveMethod::BehavioralBypass));
}

#[test]
fn pattern_store_many_domains_no_crosstalk() {
    let mut runner = TestRunner::default();
    let domains: Vec<String> = (0..50).map(|i| format!("domain{}.com", i)).collect();
    runner
        .run(&(0..50usize), |_idx| {
            let store = PatternStore::default();
            for (i, domain) in domains.iter().enumerate() {
                store.record(
                    domain,
                    &CaptchaType::RecaptchaV2,
                    i % 2 == 0,
                    100,
                    SolveMethod::BehavioralBypass,
                );
            }
            let all = store.all_patterns();
            prop_assert_eq!(all.len(), 50);
            Ok(())
        })
        .unwrap();
}

#[test]
fn pattern_store_record_after_best_method_doesnt_panic() {
    let store = PatternStore::default();
    let _ = store.best_method("example.com", &CaptchaType::CloudflareTurnstile);
    store.record(
        "example.com",
        &CaptchaType::CloudflareTurnstile,
        true,
        100,
        SolveMethod::BehavioralBypass,
    );
    let best = store.best_method("example.com", &CaptchaType::CloudflareTurnstile);
    assert_eq!(best, Some(SolveMethod::BehavioralBypass));
}