use face_verification_core::*;
use rand::SeedableRng;
#[test]
fn six_step_challenge_has_expected_shape() {
let challenge = LivenessChallenge::six_step(4, TouchTarget::Nose);
assert_eq!(challenge.steps.len(), 6);
assert_eq!(challenge.steps[4], ChallengeStep::ShowFingers { count: 4 });
}
#[test]
fn random_challenge_has_expected_constraints() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
for _ in 0..128 {
let challenge = LivenessChallenge::random(&mut rng);
assert_eq!(challenge.steps.len(), EXPECTED_PHOTO_COUNT);
assert_eq!(challenge.steps[0], ChallengeStep::FaceCentered);
assert_eq!(challenge.steps[1], ChallengeStep::Smile);
assert_ne!(challenge.steps[2], challenge.steps[3]);
match challenge.steps[4] {
ChallengeStep::ShowFingers { count } => assert!((1..=5).contains(&count)),
_ => panic!("expected fingers step"),
}
assert!(matches!(
challenge.steps[5],
ChallengeStep::TouchTarget {
target: TouchTarget::Nose | TouchTarget::Cheek | TouchTarget::Ear
}
));
}
}
#[test]
fn face_distance_validates_dimensions() {
assert_eq!(face_distance(&[1.0, 2.0], &[1.0, 2.0]).unwrap(), 0.0);
assert!(face_distance(&[1.0], &[1.0, 2.0]).is_err());
assert!(face_distance(&[], &[]).is_err());
}
#[test]
fn smile_uses_threshold() {
let thresholds = VerificationThresholds::default();
let mut frame = good_frame(0.0);
frame.face.smile_score = Some(0.39);
assert!(
!validate_pose(
&ChallengeStep::Smile,
&frame,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&thresholds,
)
.valid
);
frame.face.smile_score = Some(0.4);
assert!(
validate_pose(
&ChallengeStep::Smile,
&frame,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&thresholds,
)
.valid
);
}
#[test]
fn yaw_direction_matches_screen_arrows() {
let thresholds = VerificationThresholds::default();
let challenge = LivenessChallenge::six_step(3, TouchTarget::Nose);
assert!(
validate_pose(
&ChallengeStep::TurnLeftOnScreen,
&good_frame(0.2),
&challenge,
&thresholds,
)
.valid
);
assert!(
validate_pose(
&ChallengeStep::TurnRightOnScreen,
&good_frame(-0.2),
&challenge,
&thresholds,
)
.valid
);
assert!(
!validate_pose(
&ChallengeStep::TurnLeftOnScreen,
&good_frame(-0.2),
&challenge,
&thresholds,
)
.valid
);
}
#[test]
fn fingers_require_exact_count_by_default() {
let thresholds = VerificationThresholds::default();
let challenge = LivenessChallenge::six_step(3, TouchTarget::Nose);
let mut frame = good_frame(0.0);
frame.hands = vec![HandAnalysis {
extended_fingers: Some(2),
finger_tips: vec![],
}];
assert!(
!validate_pose(
&ChallengeStep::ShowFingers { count: 3 },
&frame,
&challenge,
&thresholds,
)
.valid
);
frame.hands[0].extended_fingers = Some(3);
assert!(
validate_pose(
&ChallengeStep::ShowFingers { count: 3 },
&frame,
&challenge,
&thresholds,
)
.valid
);
}
#[test]
fn touch_uses_normalized_distance() {
let thresholds = VerificationThresholds::default();
let challenge = LivenessChallenge::six_step(3, TouchTarget::Nose);
let mut frame = good_frame(0.0);
frame.hands = vec![HandAnalysis {
extended_fingers: Some(1),
finger_tips: vec![Point { x: 50.0, y: 50.0 }],
}];
assert!(
validate_pose(
&ChallengeStep::TouchTarget {
target: TouchTarget::Nose,
},
&frame,
&challenge,
&thresholds,
)
.valid
);
frame.hands[0].finger_tips = vec![Point { x: 100.0, y: 50.0 }];
assert!(
!validate_pose(
&ChallengeStep::TouchTarget {
target: TouchTarget::Nose,
},
&frame,
&challenge,
&thresholds,
)
.valid
);
}
#[test]
fn session_captures_after_six_consecutive_valid_frames() {
let challenge = LivenessChallenge {
steps: vec![ChallengeStep::FaceCentered],
};
let mut session = VerificationSession::new(challenge, VerificationThresholds::default());
for i in 0..5 {
assert!(matches!(
session.tick(&good_frame(0.0), i * TICK_MS),
SessionDecision::Waiting { .. }
));
}
assert!(matches!(
session.tick(&good_frame(0.0), 5 * TICK_MS),
SessionDecision::CaptureReady { .. }
));
}
#[test]
fn session_invalid_frame_resets_counter() {
let challenge = LivenessChallenge {
steps: vec![ChallengeStep::FaceCentered],
};
let mut session = VerificationSession::new(challenge, VerificationThresholds::default());
for i in 0..5 {
session.tick(&good_frame(0.0), i * TICK_MS);
}
session.tick(
&FrameAnalysis {
face: FaceAnalysis::missing(),
hands: vec![],
},
6 * TICK_MS,
);
assert!(matches!(
session.tick(&good_frame(0.0), 7 * TICK_MS),
SessionDecision::Waiting {
valid_frames: 1,
..
}
));
}
#[test]
fn verification_rejects_wrong_photo_count() {
let result = verify_captured_photos(
&[],
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert_eq!(result.error.as_deref(), Some("se esperan 6 fotos"));
}
#[test]
fn verification_rejects_duplicates() {
let mut photos = happy_photos();
photos[1].hash = photos[0].hash.clone();
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert!(!result.checks.no_duplicates);
}
#[test]
fn verification_rejects_missing_face() {
let mut photos = happy_photos();
photos[2].frame.face.detected = false;
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert!(!result.checks.all_have_face);
assert_eq!(result.checks.failed_index, Some(2));
}
#[test]
fn verification_rejects_different_person() {
let mut photos = happy_photos();
photos[3].frame.face.embedding = Some(vec![2.0, 2.0, 2.0]);
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert!(!result.checks.same_person);
}
#[test]
fn verification_rejects_too_similar_poses() {
let mut photos = happy_photos();
for photo in &mut photos {
photo.frame.face.embedding = Some(vec![0.0, 0.0, 0.0]);
}
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert!(!result.checks.poses_distinct);
}
#[test]
fn verification_rejects_invalid_final_pose() {
let mut photos = happy_photos();
photos[1].frame.face.smile_score = Some(0.0);
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(!result.ok);
assert!(!result.checks.pose_valid);
assert_eq!(result.checks.failed_index, Some(1));
}
#[test]
fn verification_happy_path_returns_embedding_and_age() {
let photos = happy_photos();
let result = verify_captured_photos(
&photos,
&LivenessChallenge::six_step(3, TouchTarget::Nose),
&VerificationThresholds::default(),
);
assert!(result.ok, "{result:?}");
assert_eq!(result.embedding, Some(vec![0.0, 0.0, 0.0]));
assert_eq!(result.estimated_age, Some(30));
assert!(result.checks.same_person);
assert!(result.checks.pose_valid);
}
#[test]
fn json_bridge_validates_pose() {
let challenge = LivenessChallenge::six_step(3, TouchTarget::Nose);
let step = ChallengeStep::Smile;
let frame = good_frame(0.0);
let thresholds = VerificationThresholds::default();
let check_json = validate_pose_json(
&serde_json::to_string(&step).unwrap(),
&serde_json::to_string(&frame).unwrap(),
&serde_json::to_string(&challenge).unwrap(),
&serde_json::to_string(&thresholds).unwrap(),
)
.unwrap();
let check: PoseCheck = serde_json::from_str(&check_json).unwrap();
assert!(check.valid);
}
#[test]
fn json_bridge_ticks_session() {
let challenge = LivenessChallenge {
steps: vec![ChallengeStep::FaceCentered],
};
let thresholds = VerificationThresholds::default();
let session_json = new_session_json(
&serde_json::to_string(&challenge).unwrap(),
&serde_json::to_string(&thresholds).unwrap(),
)
.unwrap();
let tick_json = tick_session_json(
&session_json,
&serde_json::to_string(&good_frame(0.0)).unwrap(),
0,
)
.unwrap();
let tick: SessionTickJsonResult = serde_json::from_str(&tick_json).unwrap();
assert!(matches!(
tick.decision,
SessionDecision::Waiting {
valid_frames: 1,
..
}
));
}
#[test]
fn json_bridge_verifies_captured_photos() {
let photos = happy_photos();
let challenge = LivenessChallenge::six_step(3, TouchTarget::Nose);
let thresholds = VerificationThresholds::default();
let result_json = verify_captured_photos_json(
&serde_json::to_string(&photos).unwrap(),
&serde_json::to_string(&challenge).unwrap(),
&serde_json::to_string(&thresholds).unwrap(),
)
.unwrap();
let result: VerificationResult = serde_json::from_str(&result_json).unwrap();
assert!(result.ok);
assert_eq!(result.estimated_age, Some(30));
}
#[test]
#[cfg(not(feature = "runtime-tract"))]
fn model_bundle_validation_reports_roles() {
let bundle = minimal_model_bundle();
let validation = bundle.validate().unwrap();
assert_eq!(validation.model_count, 2);
assert_eq!(
validation.roles,
vec![ModelRole::FaceDetector, ModelRole::FaceEmbedding]
);
}
#[test]
fn model_bundle_rejects_empty_bundle() {
let err = ModelBundle::default().validate().unwrap_err();
assert!(matches!(err, FaceVerificationError::InvalidModel(_)));
}
#[test]
#[cfg(feature = "runtime-tract")]
fn tract_runtime_rejects_invalid_onnx_bytes() {
let err = ModelBundle {
models: vec![model_asset(ModelRole::FaceDetector)],
}
.validate()
.unwrap_err();
assert!(matches!(err, FaceVerificationError::InvalidModel(_)));
}
#[test]
#[cfg(not(feature = "runtime-tract"))]
fn engine_requires_detector_and_embedding_models() {
let bundle = ModelBundle {
models: vec![model_asset(ModelRole::FaceDetector)],
};
let engine = FaceVerificationEngine::new(bundle, VerificationThresholds::default()).unwrap();
let err = engine.analyze_image(&image_input()).unwrap_err();
assert!(matches!(
err,
FaceVerificationError::ModelNotLoaded("face embedding")
));
}
#[test]
#[cfg(not(feature = "runtime-tract"))]
fn engine_image_analysis_boundary_is_ready_for_tract_runtime() {
let engine =
FaceVerificationEngine::new(minimal_model_bundle(), VerificationThresholds::default())
.unwrap();
let err = engine.analyze_image(&image_input()).unwrap_err();
assert!(matches!(
err,
FaceVerificationError::NotImplemented("ONNX image analysis runtime")
));
}
#[test]
#[cfg(not(feature = "runtime-tract"))]
fn json_bridge_validates_model_bundle() {
let bundle_json = serde_json::to_string(&minimal_model_bundle()).unwrap();
let validation_json = validate_model_bundle_json(&bundle_json).unwrap();
let validation: ModelValidation = serde_json::from_str(&validation_json).unwrap();
assert_eq!(validation.model_count, 2);
}
fn happy_photos() -> Vec<CapturedPhotoAnalysis> {
let yaws = [0.0, 0.0, 0.2, -0.2, 0.0, 0.0];
let embeddings = [
vec![0.0, 0.0, 0.0],
vec![0.04, 0.0, 0.0],
vec![0.08, 0.0, 0.0],
vec![0.12, 0.0, 0.0],
vec![0.16, 0.0, 0.0],
vec![0.20, 0.0, 0.0],
];
yaws.into_iter()
.enumerate()
.map(|(index, yaw)| {
let mut frame = good_frame(yaw);
frame.face.embedding = Some(embeddings[index].clone());
frame.face.smile_score = Some(0.8);
frame.face.age = Some(if index == 0 { 29.0 } else { 31.0 });
frame.hands = vec![HandAnalysis {
extended_fingers: Some(3),
finger_tips: vec![Point { x: 50.0, y: 50.0 }],
}];
CapturedPhotoAnalysis {
hash: format!("hash-{index}"),
frame,
nsfw: None,
}
})
.collect()
}
fn good_frame(yaw: f32) -> FrameAnalysis {
FrameAnalysis {
face: FaceAnalysis {
detected: true,
bounding_box: Some(BoundingBox {
x: 0.35,
y: 0.25,
width: 0.30,
height: 0.35,
}),
landmarks: Some(landmarks_for_yaw(yaw)),
embedding: Some(vec![0.0, 0.0, 0.0]),
smile_score: Some(0.8),
age: Some(30.0),
},
hands: vec![],
}
}
#[cfg(not(feature = "runtime-tract"))]
fn minimal_model_bundle() -> ModelBundle {
ModelBundle {
models: vec![
model_asset(ModelRole::FaceDetector),
model_asset(ModelRole::FaceEmbedding),
],
}
}
fn model_asset(role: ModelRole) -> ModelAsset {
ModelAsset {
role,
format: ModelFormat::Onnx,
bytes: vec![1, 2, 3],
input_width: Some(112),
input_height: Some(112),
input_name: None,
output_names: vec![],
license: None,
source: None,
}
}
#[cfg(not(feature = "runtime-tract"))]
fn image_input() -> ImageInput {
ImageInput {
bytes: vec![255, 216, 255],
width: 1,
height: 1,
mime_type: Some("image/jpeg".to_owned()),
}
}
fn landmarks_for_yaw(yaw: f32) -> Landmarks {
let mut points = vec![Point { x: 0.0, y: 0.0 }; 68];
let left = Point { x: 0.0, y: 50.0 };
let right = Point { x: 100.0, y: 50.0 };
let d_left = 50.0 * (1.0 + yaw);
let nose_x = left.x + d_left;
points[0] = left;
points[2] = Point { x: 20.0, y: 50.0 };
points[14] = Point { x: 80.0, y: 50.0 };
points[16] = right;
points[30] = Point { x: nose_x, y: 50.0 };
Landmarks { points }
}