use super::*;
#[test]
fn pattern_record_tracks_per_method_winner() {
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);
assert!((p.success_rate - 1.0).abs() < 0.01);
assert_eq!(p.avg_solve_time, 1500);
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() {
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);
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() {
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);
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() {
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);
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);
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);
}