captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! Unit tests for [`super`] (per-domain solve pattern recording + recall).

use super::*;

#[test]
fn pattern_record_tracks_per_method_winner() {
    // EMA semantics: success_rate / avg_solve_time on the pattern
    // reflect the *best* method, not the cross-method aggregate.
    let mut p = CaptchaPattern::new(
        "example.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    p.record(true, 2000, SolveMethod::BehavioralBypass);
    p.record(false, 3000, SolveMethod::VisionLLM);
    p.record(true, 1000, SolveMethod::BehavioralBypass);
    assert_eq!(p.sample_count, 3);
    assert_eq!(p.best_method, SolveMethod::BehavioralBypass);
    // BehavioralBypass: 2 samples, both success → bootstrap EMA → 1.0.
    assert!((p.success_rate - 1.0).abs() < 0.01);
    // avg_solve_time during bootstrap (n<=10): (2000*0.5)+(1000*0.5) = 1500
    assert_eq!(p.avg_solve_time, 1500);

    // Per-method stats should be tracked separately.
    assert_eq!(p.methods.len(), 2);
    let beh = p
        .methods
        .iter()
        .find(|(k, _)| k.as_str() == "behavioral_bypass")
        .map(|(_, v)| v)
        .expect("behavioral stat");
    assert_eq!(beh.sample_count, 2);
    assert!((beh.success_rate - 1.0).abs() < 0.01);
    let vlm = p
        .methods
        .iter()
        .find(|(k, _)| k.as_str() == "vision_llm")
        .map(|(_, v)| v)
        .expect("vision stat");
    assert_eq!(vlm.sample_count, 1);
    assert!(vlm.success_rate.abs() < 0.01);
}

#[test]
fn ema_recency_bias_lets_best_method_flip_within_ten_samples() {
    // Burn in 50 BehavioralBypass successes (historical winner).
    let mut p = CaptchaPattern::new(
        "shifty.test",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for _ in 0..50 {
        p.record(true, 800, SolveMethod::BehavioralBypass);
    }
    assert_eq!(p.best_method, SolveMethod::BehavioralBypass);

    // Vendor migrates: BehavioralBypass starts always failing,
    // VisionLLM starts always succeeding. Under cumulative-mean
    // (α=1/n), Behavioral's success_rate would still be ~50/60 = 0.83
    // after 10 new samples: Vision wouldn't catch up. Under EMA
    // with α=0.2, after ~10 alternating updates Vision should be
    // the winner.
    for _ in 0..10 {
        p.record(false, 1200, SolveMethod::BehavioralBypass);
        p.record(true, 1500, SolveMethod::VisionLLM);
    }
    assert_eq!(
        p.best_method,
        SolveMethod::VisionLLM,
        "EMA must let a freshly-winning method overtake within ~10 samples; \
             behavioral_rate={}, vision_rate={}",
        p.methods
            .get("behavioral_bypass")
            .map(|s| s.success_rate)
            .unwrap_or(0.0),
        p.methods
            .get("vision_llm")
            .map(|s| s.success_rate)
            .unwrap_or(0.0),
    );
}

#[test]
fn ema_bootstrap_phase_uses_cumulative_mean() {
    // First N <= EMA_BOOTSTRAP_N samples should match cumulative mean.
    let mut p = CaptchaPattern::new("x.test", CaptchaType::HCaptcha, SolveMethod::VisionLLM);
    p.record(true, 1000, SolveMethod::VisionLLM);
    p.record(true, 2000, SolveMethod::VisionLLM);
    p.record(false, 3000, SolveMethod::VisionLLM);
    // After 3 samples: cumulative success_rate = 2/3, avg = 2000.
    let stat = p.methods.get("vision_llm").unwrap();
    assert!((stat.success_rate - 2.0 / 3.0).abs() < 0.01);
    assert_eq!(stat.avg_solve_time, 2000);
}

#[test]
fn pattern_store_record_and_lookup() {
    let store = PatternStore::default();
    store.record(
        "example.com",
        &CaptchaType::RecaptchaV2,
        true,
        1500,
        SolveMethod::AudioBypass,
    );
    let method = store.best_method("example.com", &CaptchaType::RecaptchaV2);
    assert_eq!(method, Some(SolveMethod::AudioBypass));
}

#[test]
fn pattern_store_unknown_returns_none() {
    let store = PatternStore::default();
    assert!(store
        .best_method("unknown.com", &CaptchaType::Slider)
        .is_none());
}

#[test]
fn save_then_load_round_trips_recorded_patterns() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("patterns.json");

    let original = PatternStore::default();
    original.record(
        "alpha.test",
        &CaptchaType::HCaptcha,
        true,
        1234,
        SolveMethod::VisionLLM,
    );
    original.record(
        "beta.test",
        &CaptchaType::CloudflareTurnstile,
        true,
        500,
        SolveMethod::BehavioralBypass,
    );
    original.save_to_path(&path).expect("save");

    let loaded = PatternStore::load_from_path(&path).expect("load");
    assert_eq!(
        loaded.best_method("alpha.test", &CaptchaType::HCaptcha),
        Some(SolveMethod::VisionLLM),
    );
    assert_eq!(
        loaded.best_method("beta.test", &CaptchaType::CloudflareTurnstile),
        Some(SolveMethod::BehavioralBypass),
    );
    assert_eq!(loaded.all_patterns().len(), 2);
}

#[test]
fn save_to_path_is_atomic_against_concurrent_reads() {
    // Atomic semantics: the temp file is renamed into place. We
    // can't easily race in a unit test, but we can confirm the
    // .tmp file is cleaned up after a successful save.
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("patterns.json");
    let store = PatternStore::default();
    store.record(
        "x.test",
        &CaptchaType::HCaptcha,
        true,
        1000,
        SolveMethod::VisionLLM,
    );
    store.save_to_path(&path).unwrap();
    assert!(path.exists());
    assert!(
        !path.with_extension("tmp").exists(),
        "temp file should be renamed away"
    );
}

#[test]
fn load_from_path_errors_on_missing_file() {
    let res = PatternStore::load_from_path("/definitely/no/such/file.json");
    assert!(res.is_err());
}

#[test]
fn load_or_default_returns_empty_when_file_missing() {
    let store = PatternStore::load_or_default("/definitely/no/such/file.json");
    assert!(store.all_patterns().is_empty());
}

#[test]
fn merge_keeps_higher_sample_count_winner() {
    let a = PatternStore::default();
    a.record(
        "shared.test",
        &CaptchaType::HCaptcha,
        true,
        1000,
        SolveMethod::AudioBypass,
    );

    let b = PatternStore::default();
    for _ in 0..5 {
        b.record(
            "shared.test",
            &CaptchaType::HCaptcha,
            true,
            500,
            SolveMethod::VisionLLM,
        );
    }

    a.merge(&b);
    // b had 5 samples, a had 1 (b's VisionLLM wins).
    assert_eq!(
        a.best_method("shared.test", &CaptchaType::HCaptcha),
        Some(SolveMethod::VisionLLM),
    );
}

#[test]
fn merge_keeps_existing_when_self_has_more_samples() {
    let a = PatternStore::default();
    for _ in 0..10 {
        a.record(
            "shared.test",
            &CaptchaType::HCaptcha,
            true,
            500,
            SolveMethod::VisionLLM,
        );
    }
    let b = PatternStore::default();
    b.record(
        "shared.test",
        &CaptchaType::HCaptcha,
        true,
        800,
        SolveMethod::AudioBypass,
    );

    a.merge(&b);
    // a had 10 samples, b had 1 (a's VisionLLM wins).
    assert_eq!(
        a.best_method("shared.test", &CaptchaType::HCaptcha),
        Some(SolveMethod::VisionLLM),
    );
}

#[test]
fn merge_inserts_disjoint_keys() {
    let a = PatternStore::default();
    a.record(
        "a.test",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::VisionLLM,
    );
    let b = PatternStore::default();
    b.record(
        "b.test",
        &CaptchaType::HCaptcha,
        true,
        100,
        SolveMethod::AudioBypass,
    );
    a.merge(&b);
    assert_eq!(a.all_patterns().len(), 2);
}

#[test]
fn pattern_store_all_patterns() {
    let store = PatternStore::default();
    store.record(
        "a.com",
        &CaptchaType::HCaptcha,
        true,
        1000,
        SolveMethod::VisionLLM,
    );
    store.record(
        "b.com",
        &CaptchaType::RecaptchaV2,
        false,
        2000,
        SolveMethod::AudioBypass,
    );
    assert_eq!(store.all_patterns().len(), 2);
}