use captchaforge::solver::{CaptchaPattern, CaptchaType, PatternStore, SolveMethod};
use std::sync::Arc;
#[test]
fn empty_store_returns_none_for_unknown_domain() {
let s = PatternStore::default();
assert!(s
.best_method("never-seen.com", &CaptchaType::CloudflareTurnstile)
.is_none());
}
#[test]
fn record_then_lookup_returns_the_method() {
let s = PatternStore::default();
s.record(
"example.com",
&CaptchaType::HCaptcha,
true,
500,
SolveMethod::BehavioralBypass,
);
let got = s.best_method("example.com", &CaptchaType::HCaptcha);
assert_eq!(got, Some(SolveMethod::BehavioralBypass));
}
#[test]
fn different_captcha_types_on_same_domain_are_keyed_independently() {
let s = PatternStore::default();
s.record(
"x.com",
&CaptchaType::CloudflareTurnstile,
true,
100,
SolveMethod::BehavioralBypass,
);
s.record(
"x.com",
&CaptchaType::HCaptcha,
true,
200,
SolveMethod::VisionLLM,
);
assert_eq!(
s.best_method("x.com", &CaptchaType::CloudflareTurnstile),
Some(SolveMethod::BehavioralBypass)
);
assert_eq!(
s.best_method("x.com", &CaptchaType::HCaptcha),
Some(SolveMethod::VisionLLM)
);
}
#[test]
fn ties_break_to_faster_method() {
let s = PatternStore::default();
s.record(
"y.com",
&CaptchaType::CloudflareTurnstile,
true,
100,
SolveMethod::BehavioralBypass,
);
s.record(
"y.com",
&CaptchaType::CloudflareTurnstile,
true,
200,
SolveMethod::VisionLLM,
);
assert_eq!(
s.best_method("y.com", &CaptchaType::CloudflareTurnstile),
Some(SolveMethod::BehavioralBypass),
"tied success rates resolve to the faster method",
);
}
#[test]
fn failures_do_not_overwrite_best_method() {
let s = PatternStore::default();
s.record(
"z.com",
&CaptchaType::CloudflareTurnstile,
true,
100,
SolveMethod::BehavioralBypass,
);
s.record(
"z.com",
&CaptchaType::CloudflareTurnstile,
false,
9999,
SolveMethod::AudioBypass,
);
assert_eq!(
s.best_method("z.com", &CaptchaType::CloudflareTurnstile),
Some(SolveMethod::BehavioralBypass),
"AudioBypass failed; should NOT replace BehavioralBypass"
);
}
#[test]
fn all_patterns_returns_one_entry_per_domain_type_pair() {
let s = PatternStore::default();
s.record(
"a.com",
&CaptchaType::HCaptcha,
true,
100,
SolveMethod::VisionLLM,
);
s.record(
"b.com",
&CaptchaType::HCaptcha,
true,
100,
SolveMethod::VisionLLM,
);
s.record(
"a.com",
&CaptchaType::CloudflareTurnstile,
true,
100,
SolveMethod::BehavioralBypass,
);
let mut domains: Vec<String> = s.all_patterns().into_iter().map(|p| p.domain).collect();
domains.sort();
assert_eq!(domains, vec!["a.com", "a.com", "b.com"]);
}
#[test]
fn success_rate_recent_failures_dominate_under_ema() {
let mut p = CaptchaPattern::new(
"ex.com",
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
for _ in 0..70 {
p.record(true, 100, SolveMethod::BehavioralBypass);
}
let before_failures = p.success_rate;
for _ in 0..30 {
p.record(false, 100, SolveMethod::BehavioralBypass);
}
assert_eq!(p.sample_count, 100);
assert!(
before_failures > 0.9,
"after 70 wins, EMA should be near 1.0; got {before_failures}",
);
assert!(
p.success_rate < 0.05,
"after 30 trailing failures, EMA should fall below 0.05; got {}",
p.success_rate
);
}
#[test]
fn avg_solve_time_converges_to_observed_mean() {
let mut p = CaptchaPattern::new(
"ex.com",
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
for ms in [100u64, 200, 300, 400, 500] {
p.record(true, ms, SolveMethod::BehavioralBypass);
}
assert_eq!(p.sample_count, 5);
assert_eq!(
p.avg_solve_time, 300,
"mean of 100..500 step 100 is 300, got {}",
p.avg_solve_time
);
}
#[test]
fn pattern_store_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PatternStore>();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_records_do_not_drop_observations() {
let s = Arc::new(PatternStore::default());
let mut handles = Vec::new();
for thread_id in 0..8u64 {
let s = s.clone();
handles.push(tokio::spawn(async move {
for _ in 0..125 {
s.record(
"concurrent.com",
&CaptchaType::CloudflareTurnstile,
true,
100 + thread_id * 10,
SolveMethod::BehavioralBypass,
);
}
}));
}
for h in handles {
h.await.unwrap();
}
let patterns = s.all_patterns();
assert_eq!(patterns.len(), 1, "single domain+type → single pattern");
assert_eq!(
patterns[0].sample_count, 1000,
"8 threads × 125 records = 1000 (no lost updates)"
);
}
#[test]
fn pattern_clone_is_independent_of_original() {
let mut p1 = CaptchaPattern::new(
"ex.com",
CaptchaType::HCaptcha,
SolveMethod::BehavioralBypass,
);
p1.record(true, 100, SolveMethod::BehavioralBypass);
let p2 = p1.clone();
p1.record(true, 200, SolveMethod::BehavioralBypass);
assert_eq!(
p2.sample_count, 1,
"clone must not share state with original"
);
assert_eq!(p1.sample_count, 2);
}