use captchaforge::solver::{
CaptchaPattern, CaptchaSolveResult, CaptchaType, PatternStore, SolveConfig, SolveMethod,
};
use proptest::prelude::*;
use proptest::test_runner::TestRunner;
#[test]
fn failure_result_never_success() {
let mut runner = TestRunner::default();
runner
.run(&(any_solve_method(), 0u64..10_000), |(method, time_ms)| {
let result = CaptchaSolveResult::failure(method.clone(), time_ms);
prop_assert!(!result.success);
prop_assert!(result.solution.is_empty());
prop_assert_eq!(result.confidence, 0.0);
prop_assert_eq!(result.method, method);
prop_assert_eq!(result.time_ms, time_ms);
Ok(())
})
.unwrap();
}
#[test]
fn unsolved_result_never_success() {
let mut runner = TestRunner::default();
runner
.run(&(0u64..10_000), |time_ms| {
let result = CaptchaSolveResult::unsolved(time_ms, None);
prop_assert!(!result.success);
prop_assert!(result.solution.is_empty());
prop_assert_eq!(result.confidence, 0.0);
prop_assert_eq!(result.time_ms, time_ms);
Ok(())
})
.unwrap();
}
#[test]
fn confidence_in_range() {
let mut runner = TestRunner::default();
runner
.run(&(0.0f32..=1.0f32), |confidence| {
let result = CaptchaSolveResult {
solution: "test".to_string(),
confidence,
method: SolveMethod::BehavioralBypass,
time_ms: 100,
success: true,
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
};
prop_assert!(result.confidence >= 0.0);
prop_assert!(result.confidence <= 1.0);
Ok(())
})
.unwrap();
}
#[test]
fn time_ms_non_negative() {
let mut runner = TestRunner::default();
runner
.run(&(0u64..u64::MAX), |time_ms| {
let result = CaptchaSolveResult {
solution: String::new(),
confidence: 0.0,
method: SolveMethod::BehavioralBypass,
time_ms,
success: false,
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
};
prop_assert_eq!(result.time_ms, time_ms);
Ok(())
})
.unwrap();
}
#[test]
fn solve_config_defaults_positive() {
let cfg = SolveConfig::default();
assert!(cfg.checkbox_poll_interval_ms > 0);
assert!(cfg.checkbox_max_attempts > 0);
assert!(cfg.token_poll_interval_ms > 0);
assert!(cfg.token_max_attempts > 0);
assert!(cfg.audio_button_delay_ms > 0);
assert!(cfg.audio_submit_delay_ms > 0);
assert!(cfg.vlm_http_timeout_ms > 0);
assert!(cfg.client_http_timeout_ms > 0);
}
#[test]
fn pattern_success_rate_bounded() {
let mut runner = TestRunner::default();
runner
.run(
&proptest::collection::vec((proptest::bool::ANY, 10u64..1000u64), 0..200),
|records| {
let mut pattern = CaptchaPattern::new(
"example.com".to_string(),
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
for (success, time_ms) in records {
pattern.record(success, time_ms, SolveMethod::BehavioralBypass);
prop_assert!(pattern.success_rate >= 0.0);
prop_assert!(pattern.success_rate <= 1.0);
prop_assert!(pattern.sample_count > 0);
}
Ok(())
},
)
.unwrap();
}
#[test]
fn pattern_store_concurrent_records_preserved() {
let mut runner = TestRunner::default();
let non_custom = prop_oneof![
Just(CaptchaType::RecaptchaV2),
Just(CaptchaType::RecaptchaV3),
Just(CaptchaType::HCaptcha),
Just(CaptchaType::CloudflareTurnstile),
Just(CaptchaType::ImageGrid),
Just(CaptchaType::TextCaptcha),
Just(CaptchaType::AudioCaptcha),
Just(CaptchaType::Slider),
];
let strategy = proptest::collection::vec(
(
non_custom,
any_solve_method(),
proptest::bool::ANY,
10u64..1000u64,
),
1..50usize,
);
runner
.run(&strategy, |records| {
use std::sync::Arc;
use std::thread;
let store = Arc::new(PatternStore::default());
let mut handles = Vec::new();
for (captcha_type, method, success, time_ms) in records {
let store = Arc::clone(&store);
handles.push(thread::spawn(move || {
store.record("example.com", &captcha_type, success, time_ms, method);
}));
}
for h in handles {
h.join().unwrap();
}
let patterns = store.all_patterns();
prop_assert!(patterns.len() <= 8); Ok(())
})
.unwrap();
}
#[test]
fn captcha_type_display_serde_consistent() {
let mut runner = TestRunner::default();
let non_custom = prop_oneof![
Just(CaptchaType::RecaptchaV2),
Just(CaptchaType::RecaptchaV3),
Just(CaptchaType::HCaptcha),
Just(CaptchaType::CloudflareTurnstile),
Just(CaptchaType::ImageGrid),
Just(CaptchaType::TextCaptcha),
Just(CaptchaType::AudioCaptcha),
Just(CaptchaType::Slider),
];
runner
.run(&non_custom, |captcha_type| {
let display = captcha_type.to_string();
let json = serde_json::to_string(&captcha_type).unwrap();
let json_str: String = serde_json::from_str(&json).unwrap();
prop_assert_eq!(display, json_str);
Ok(())
})
.unwrap();
}
#[test]
fn solve_method_serde_roundtrip() {
let mut runner = TestRunner::default();
runner
.run(&any_solve_method(), |method| {
let json = serde_json::to_string(&method).unwrap();
let back: SolveMethod = serde_json::from_str(&json).unwrap();
prop_assert_eq!(method, back);
Ok(())
})
.unwrap();
}
#[test]
fn empty_store_returns_none() {
let mut runner = TestRunner::default();
runner
.run(&any_captcha_type(), |captcha_type| {
let store = PatternStore::default();
prop_assert!(store.best_method("unknown.com", &captcha_type).is_none());
Ok(())
})
.unwrap();
}
#[test]
fn success_implies_method_set() {
let mut runner = TestRunner::default();
runner
.run(&any_solve_method(), |method| {
let result = CaptchaSolveResult {
solution: "token".to_string(),
confidence: 0.95,
method: method.clone(),
time_ms: 500,
success: true,
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
};
prop_assert!(result.success);
prop_assert_eq!(result.method, method);
Ok(())
})
.unwrap();
}
fn any_solve_method() -> impl Strategy<Value = SolveMethod> {
prop_oneof![
Just(SolveMethod::VisionLLM),
Just(SolveMethod::AudioBypass),
Just(SolveMethod::BehavioralBypass),
Just(SolveMethod::ThirdPartyService),
Just(SolveMethod::CrowdSourced),
]
}
fn any_captcha_type() -> impl Strategy<Value = CaptchaType> {
prop_oneof![
Just(CaptchaType::RecaptchaV2),
Just(CaptchaType::RecaptchaV3),
Just(CaptchaType::HCaptcha),
Just(CaptchaType::CloudflareTurnstile),
Just(CaptchaType::ImageGrid),
Just(CaptchaType::TextCaptcha),
Just(CaptchaType::AudioCaptcha),
Just(CaptchaType::Slider),
("[a-z0-9_-]{1,32}").prop_map(CaptchaType::Custom),
]
}